feat: implement responsive tab plugin and new hover card v3 component, and add admin controllers for price and recruiting tables.
@@ -42,3 +42,6 @@ Instead, ALWAYS use the `activeTheme` variable to dynamically load the active th
|
||||
**Incorrect Usage:**
|
||||
`<div th:replace="~{themes/umass/header}"></div>`
|
||||
`<footer th:replace="~{fragments/footer :: footer}"></footer>`
|
||||
|
||||
### Admin Panel UI Pattern: Lists
|
||||
When building admin panels to manage lists of items, DO NOT use inline `<input>` fields for all rows by default. Instead, use a **read-only view** for the list items, providing explicit **"Edit"** and **"Delete"** buttons for each row. Clicking "Edit" should open a modal or expand the row to allow editing the fields.
|
||||
|
||||
@@ -3,380 +3,278 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Thành tựu và Bài viết Bệnh viện</title>
|
||||
<title>Trang Tin Tức - Bài Viết Nổi Bật</title>
|
||||
<style>
|
||||
/* ===================================================
|
||||
1. RESET CSS & FONT CƠ BẢN
|
||||
1. RESET & BASIC STYLES
|
||||
=================================================== */
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||
"Helvetica Neue", Arial, sans-serif;
|
||||
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial,
|
||||
sans-serif;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: #f8fafc;
|
||||
color: #1e293b;
|
||||
padding: 40px 20px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.news-container {
|
||||
/* ===================================================
|
||||
2. GRID LAYOUT CHÍNH (12 CỘT)
|
||||
=================================================== */
|
||||
.news-section {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* ===================================================
|
||||
2. FILTER TABS (THANH LỌC DANH MỤC)
|
||||
=================================================== */
|
||||
.filter-tabs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
padding: 8px 18px;
|
||||
border: none;
|
||||
background-color: #e2e8f0;
|
||||
color: #475569;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.tab-btn:hover {
|
||||
background-color: #cbd5e1;
|
||||
}
|
||||
|
||||
.tab-btn.active {
|
||||
background-color: #0284c7; /* Màu xanh chủ đạo y tế */
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
/* ===================================================
|
||||
3. CARD GRID (LƯỚI 3 CỘT chuẩn UI)
|
||||
=================================================== */
|
||||
.news-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 24px;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.news-card {
|
||||
width: 100%;
|
||||
background-color: #ffffff;
|
||||
padding: 24px;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.05);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition:
|
||||
transform 0.25s ease,
|
||||
box-shadow 0.25s ease;
|
||||
cursor: pointer;
|
||||
border: 1px solid #f1f5f9;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.05);
|
||||
|
||||
display: grid;
|
||||
grid-template-columns: repeat(12, 1fr);
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.news-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* THUMBNAIL & BADGE NĂM */
|
||||
.card-thumb {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9; /* Cố định tỉ lệ hình ảnh */
|
||||
overflow: hidden;
|
||||
background-color: #e2e8f0;
|
||||
}
|
||||
|
||||
.card-thumb img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.year-badge {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
background-color: #0284c7;
|
||||
color: #ffffff;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
padding: 4px 10px;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
/* NỘI DUNG CARD */
|
||||
.card-body {
|
||||
padding: 16px 18px 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 14px;
|
||||
/* Component thẻ Badge (TIN TỨC / SỰ KIỆN) */
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 4px 8px;
|
||||
background-color: #f1f5f9;
|
||||
color: #475569;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: #0284c7; /* Chữ xanh y tế */
|
||||
line-height: 1.45;
|
||||
|
||||
/* Cắt bớt văn bản thành 3 dòng kèm dấu ... */
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
text-transform: uppercase;
|
||||
border-radius: 4px;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
/* ===================================================
|
||||
4. PHÂN TRANG (PAGINATION)
|
||||
=================================================== */
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
list-style: none;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.page-link {
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid #e2e8f0;
|
||||
background-color: #ffffff;
|
||||
color: #64748b;
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.page-link:hover {
|
||||
border-color: #0284c7;
|
||||
color: #0284c7;
|
||||
}
|
||||
|
||||
.page-link.active {
|
||||
background-color: #0284c7;
|
||||
color: #ffffff;
|
||||
border-color: #0284c7;
|
||||
}
|
||||
|
||||
.page-dots {
|
||||
padding: 0 4px;
|
||||
.post-date {
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
/* ===================================================
|
||||
5. RESPONSIVE
|
||||
3. CỘT TRÁI - BÀI VIẾT NỔI BẬT (FEATURED POST)
|
||||
=================================================== */
|
||||
.featured-post {
|
||||
grid-column: span 6; /* Chiếm 6/12 cột */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.featured-img-box {
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.featured-img-box img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.featured-post:hover .featured-img-box img {
|
||||
transform: scale(1.03);
|
||||
}
|
||||
|
||||
.meta-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.featured-title {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: #0284c7; /* Màu xanh chủ đạo */
|
||||
line-height: 1.3;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.featured-title:hover {
|
||||
color: #0369a1;
|
||||
}
|
||||
|
||||
.featured-excerpt {
|
||||
font-size: 14px;
|
||||
color: #475569;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* ===================================================
|
||||
4. CỘT PHẢI - LƯỚI 4 BÀI NHỎ (GRID 2x2)
|
||||
=================================================== */
|
||||
.side-news-grid {
|
||||
grid-column: span 6; /* Chiếm 6/12 cột còn lại */
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr); /* Tách thành 2 cột nhỏ */
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.small-post {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.small-img-box {
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 10;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.small-img-box img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.small-post:hover .small-img-box img {
|
||||
transform: scale(1.04);
|
||||
}
|
||||
|
||||
.small-title {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: #1e293b;
|
||||
line-height: 1.4;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3; /* Giới hạn tối đa 3 dòng */
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.small-post:hover .small-title {
|
||||
color: #0284c7;
|
||||
}
|
||||
|
||||
/* ===================================================
|
||||
5. RESPONSIVE (Cho Tablet & Mobile)
|
||||
=================================================== */
|
||||
@media (max-width: 992px) {
|
||||
.news-grid {
|
||||
grid-template-columns: repeat(2, 1fr); /* Tablet chia 2 cột */
|
||||
.featured-post,
|
||||
.side-news-grid {
|
||||
grid-column: span 12; /* Chuyển thành 1 cột dọc lớn */
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
.news-grid {
|
||||
grid-template-columns: 1fr; /* Mobile hiển thị 1 cột */
|
||||
.side-news-grid {
|
||||
grid-template-columns: 1fr; /* Lưới bài nhỏ chuyển thành 1 cột trên Mobile */
|
||||
}
|
||||
.filter-tabs {
|
||||
justify-content: flex-start;
|
||||
.featured-title {
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="news-container">
|
||||
<!-- 1. TAB LỌC NỘI DUNG -->
|
||||
<div class="filter-tabs">
|
||||
<button class="tab-btn active">Tất cả</button>
|
||||
<button class="tab-btn">Điều trị</button>
|
||||
<button class="tab-btn">Đào tạo - Nghiên cứu khoa học</button>
|
||||
<button class="tab-btn">Quản trị Bệnh viện</button>
|
||||
</div>
|
||||
<section class="news-section">
|
||||
<!-- CỘT TRÁI: Bài viết nổi bật lớn -->
|
||||
<article class="featured-post">
|
||||
<div class="featured-img-box">
|
||||
<img
|
||||
src="https://picsum.photos/800/450?random=1"
|
||||
alt="Nhà giáo nhân dân Nguyễn Đình Hối"
|
||||
/>
|
||||
</div>
|
||||
<div class="meta-row">
|
||||
<span class="badge">Tin tức</span>
|
||||
<span class="post-date">06/07/2026</span>
|
||||
</div>
|
||||
<a href="#" class="featured-title">Thư cảm tạ</a>
|
||||
<p class="featured-excerpt">
|
||||
Đại học Y Dược Thành phố Hồ Chí Minh, Bệnh viện Đại học Y Dược Thành
|
||||
phố Hồ Chí Minh và gia quyến vô cùng xúc động và trân trọng gửi lời
|
||||
cảm tạ sâu sắc.
|
||||
</p>
|
||||
</article>
|
||||
|
||||
<!-- 2. DANH SÁCH BÀI VIẾT dạng LƯỚI GRID -->
|
||||
<div class="news-grid">
|
||||
<!-- Card 1 -->
|
||||
<article class="news-card">
|
||||
<div class="card-thumb">
|
||||
<img
|
||||
src="https://picsum.photos/400/250?random=1"
|
||||
alt="Bài viết 1"
|
||||
/>
|
||||
<span class="year-badge">2026</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<h3 class="card-title">
|
||||
Bệnh viện Đại học Y Dược TP. Hồ Chí Minh ghi dấu ấn tại giải
|
||||
thưởng "Đại sứ truyền thông...
|
||||
</h3>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<!-- Card 2 -->
|
||||
<article class="news-card">
|
||||
<div class="card-thumb">
|
||||
<!-- CỘT PHẢI: Lưới 4 bài viết nhỏ (2x2) -->
|
||||
<div class="side-news-grid">
|
||||
<!-- Bài nhỏ 1 -->
|
||||
<a href="#" class="small-post">
|
||||
<div class="small-img-box">
|
||||
<img
|
||||
src="https://picsum.photos/400/250?random=2"
|
||||
alt="Bài viết 2"
|
||||
alt="Tuần lễ thế giới nuôi con bằng sữa mẹ"
|
||||
/>
|
||||
<span class="year-badge">2026</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<h3 class="card-title">
|
||||
Tác phẩm "Tuyển tập những bệnh án lâm sàng đặc sắc" đạt giải B
|
||||
giải thưởng sách quốc gia...
|
||||
</h3>
|
||||
<div class="meta-row">
|
||||
<span class="badge">Sự kiện</span>
|
||||
</div>
|
||||
</article>
|
||||
<h3 class="small-title">
|
||||
Tuần lễ thế giới nuôi con bằng sữa mẹ 2026
|
||||
</h3>
|
||||
<span class="post-date">04/08/2026</span>
|
||||
</a>
|
||||
|
||||
<!-- Card 3 -->
|
||||
<article class="news-card">
|
||||
<div class="card-thumb">
|
||||
<!-- Bài nhỏ 2 -->
|
||||
<a href="#" class="small-post">
|
||||
<div class="small-img-box">
|
||||
<img
|
||||
src="https://picsum.photos/400/250?random=3"
|
||||
alt="Bài viết 3"
|
||||
alt="Đau lưng ở nhân viên văn phòng"
|
||||
/>
|
||||
<span class="year-badge">2026</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<h3 class="card-title">
|
||||
Trái tim từ bắc vào nam hồi sinh sự sống cho bệnh nhi ngày đầu
|
||||
xuân mới
|
||||
</h3>
|
||||
<div class="meta-row">
|
||||
<span class="badge">Tin tức</span>
|
||||
</div>
|
||||
</article>
|
||||
<h3 class="small-title">
|
||||
Đau lưng ở nhân viên văn phòng: Đừng đợi đến khi cột sống “lên
|
||||
tiếng”
|
||||
</h3>
|
||||
<span class="post-date">01/08/2026</span>
|
||||
</a>
|
||||
|
||||
<!-- Card 4 -->
|
||||
<article class="news-card">
|
||||
<div class="card-thumb">
|
||||
<!-- Bài nhỏ 3 -->
|
||||
<a href="#" class="small-post">
|
||||
<div class="small-img-box">
|
||||
<img
|
||||
src="https://picsum.photos/400/250?random=4"
|
||||
alt="Bài viết 4"
|
||||
alt="Bệnh lý miễn dịch thần kinh"
|
||||
/>
|
||||
<span class="year-badge">2025</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<h3 class="card-title">
|
||||
Ca ghép tim xuyên đêm tại Bệnh viện Đại học Y Dược TP. Hồ Chí
|
||||
Minh: Cuộc chạy đua sinh tử...
|
||||
</h3>
|
||||
<div class="meta-row">
|
||||
<span class="badge">Tin tức</span>
|
||||
</div>
|
||||
</article>
|
||||
<h3 class="small-title">
|
||||
‘Bệnh lý miễn dịch thần kinh - Phát hiện sớm để điều trị tốt’
|
||||
</h3>
|
||||
<span class="post-date">31/07/2026</span>
|
||||
</a>
|
||||
|
||||
<!-- Card 5 -->
|
||||
<article class="news-card">
|
||||
<div class="card-thumb">
|
||||
<!-- Bài nhỏ 4 -->
|
||||
<a href="#" class="small-post">
|
||||
<div class="small-img-box">
|
||||
<img
|
||||
src="https://picsum.photos/400/250?random=5"
|
||||
alt="Bài viết 5"
|
||||
alt="Phẫu thuật nội soi"
|
||||
/>
|
||||
<span class="year-badge">2025</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<h3 class="card-title">
|
||||
Đại học Y Dược TP. Hồ Chí Minh công nhận 02 nhóm nghiên cứu tiềm
|
||||
năng mạnh đầu tiên
|
||||
</h3>
|
||||
<div class="meta-row">
|
||||
<span class="badge">Tin tức</span>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<!-- Card 6 -->
|
||||
<article class="news-card">
|
||||
<div class="card-thumb">
|
||||
<img
|
||||
src="https://picsum.photos/400/250?random=6"
|
||||
alt="Bài viết 6"
|
||||
/>
|
||||
<span class="year-badge">2025</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<h3 class="card-title">
|
||||
Bệnh viện Đại học Y Dược TP. Hồ Chí Minh - Điển hình tiên tiến
|
||||
Ngành Y tế tại Đại hội Thi đ...
|
||||
</h3>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<!-- Card 7 -->
|
||||
<article class="news-card">
|
||||
<div class="card-thumb">
|
||||
<img
|
||||
src="https://picsum.photos/400/250?random=7"
|
||||
alt="Bài viết 7"
|
||||
/>
|
||||
<span class="year-badge">2025</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<h3 class="card-title">
|
||||
Bệnh viện Đại học Y Dược TP. Hồ Chí Minh tổ chức lễ kỷ niệm 100 ca
|
||||
TAVI thành công trao...
|
||||
</h3>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<!-- Card 8 -->
|
||||
<article class="news-card">
|
||||
<div class="card-thumb">
|
||||
<img
|
||||
src="https://picsum.photos/400/250?random=8"
|
||||
alt="Bài viết 8"
|
||||
/>
|
||||
<span class="year-badge">2025</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<h3 class="card-title">
|
||||
Bệnh viện Đại học Y Dược TP. Hồ Chí Minh đạt giải "Vệ sinh tay
|
||||
xuất sắc năm 2025"
|
||||
</h3>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<!-- Card 9 -->
|
||||
<article class="news-card">
|
||||
<div class="card-thumb">
|
||||
<img
|
||||
src="https://picsum.photos/400/250?random=9"
|
||||
alt="Bài viết 9"
|
||||
/>
|
||||
<span class="year-badge">2025</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<h3 class="card-title">
|
||||
Tiến bộ trong y học bào thai: Can thiệp thành công cứu sống thai
|
||||
nhi mắc khối u nhau thai...
|
||||
</h3>
|
||||
</div>
|
||||
</article>
|
||||
<h3 class="small-title">
|
||||
Phẫu thuật nội soi cắt phì đại liên thất và thay van động mạch chủ
|
||||
</h3>
|
||||
<span class="post-date">30/07/2026</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- 3. THANH PHÂN TRANG -->
|
||||
<ul class="pagination">
|
||||
<li><a href="#" class="page-link">«</a></li>
|
||||
<li><a href="#" class="page-link">‹</a></li>
|
||||
<li><a href="#" class="page-link active">1</a></li>
|
||||
<li><a href="#" class="page-link">2</a></li>
|
||||
<li><a href="#" class="page-link">3</a></li>
|
||||
<li><span class="page-dots">...</span></li>
|
||||
<li><a href="#" class="page-link">6</a></li>
|
||||
<li><a href="#" class="page-link">›</a></li>
|
||||
<li><a href="#" class="page-link">»</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
<style>
|
||||
/* Bento Layout 02 (Featured 1 + 2x2 Grid) */
|
||||
.news-section-v3 {
|
||||
max-width: 1200px;
|
||||
width: 100%;
|
||||
background-color: #ffffff;
|
||||
padding: 24px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.05);
|
||||
display: grid;
|
||||
grid-template-columns: repeat(12, 1fr);
|
||||
gap: 24px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.news-section-v3 .badge-news {
|
||||
display: inline-block;
|
||||
padding: 4px 8px;
|
||||
background-color: #f1f5f9;
|
||||
color: #475569;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
border-radius: 4px;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.news-section-v3 .post-date {
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
.news-section-v3 .featured-post {
|
||||
grid-column: span 6;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
.news-section-v3 .featured-img-box {
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.news-section-v3 .featured-img-box img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
.news-section-v3 .featured-post:hover .featured-img-box img {
|
||||
transform: scale(1.03);
|
||||
}
|
||||
.news-section-v3 .meta-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.news-section-v3 .featured-title {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: #0284c7;
|
||||
line-height: 1.3;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
.news-section-v3 .featured-title:hover {
|
||||
color: #0369a1;
|
||||
}
|
||||
.news-section-v3 .featured-excerpt {
|
||||
font-size: 14px;
|
||||
color: #475569;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.news-section-v3 .side-news-grid {
|
||||
grid-column: span 6;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 20px;
|
||||
}
|
||||
.news-section-v3 .small-post {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
.news-section-v3 .small-img-box {
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 10;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.news-section-v3 .small-img-box img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
.news-section-v3 .small-post:hover .small-img-box img {
|
||||
transform: scale(1.04);
|
||||
}
|
||||
.news-section-v3 .small-title {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: #1e293b;
|
||||
line-height: 1.4;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
margin: 0;
|
||||
}
|
||||
.news-section-v3 .small-post:hover .small-title {
|
||||
color: #0284c7;
|
||||
}
|
||||
@media (max-width: 992px) {
|
||||
.news-section-v3 .featured-post,
|
||||
.news-section-v3 .side-news-grid {
|
||||
grid-column: span 12;
|
||||
}
|
||||
}
|
||||
@media (max-width: 576px) {
|
||||
.news-section-v3 .side-news-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.news-section-v3 .featured-title {
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="news-section-v3" th:if="${posts != null}">
|
||||
<th:block th:with="filteredPosts=${posts}">
|
||||
<article class="featured-post">
|
||||
<div class="featured-img-box" th:if="${(block.data['showImage'] == null or block.data['showImage'] == true) and firstPost.featuredImage != null and !#strings.isEmpty(firstPost.featuredImage)}">
|
||||
<img th:src="${firstPost.featuredImage}" th:alt="${firstPost.title}" />
|
||||
</div>
|
||||
<div class="meta-row">
|
||||
<span class="badge-news" th:if="${firstPost.category != null}" th:text="${firstPost.category.name}">Tin tức</span>
|
||||
<span class="post-date" th:if="${(block.data['showDate'] == null or block.data['showDate'] == true) and firstPost.createdDate != null}" th:text="${#temporals.format(firstPost.createdDate, 'dd/MM/yyyy')}">06/07/2026</span>
|
||||
</div>
|
||||
<a th:href="@{'/posts/' + ${firstPost.slug}}" class="featured-title" th:utext="${firstPost.title}">Featured Post Title</a>
|
||||
<p class="featured-excerpt" th:if="${(block.data['showExcerpt'] == null or block.data['showExcerpt'] == true) and firstPost.excerpt != null and !#strings.isEmpty(firstPost.excerpt)}" th:utext="${firstPost.excerpt}">Featured Excerpt...</p>
|
||||
</article>
|
||||
</th:block>
|
||||
|
||||
<!-- CỘT PHẢI: Lưới 4 bài viết nhỏ (2x2) -->
|
||||
<div class="side-news-grid">
|
||||
<th:block th:each="p, iterStat : ${posts}" th:if="${iterStat.index > 0 and iterStat.index <= 4}">
|
||||
<a th:href="@{'/posts/' + ${p.slug}}" class="small-post">
|
||||
<div class="small-img-box" th:if="${(block.data['showImage'] == null or block.data['showImage'] == true) and p.featuredImage != null and !#strings.isEmpty(p.featuredImage)}">
|
||||
<img th:src="${p.featuredImage}" th:alt="${p.title}" />
|
||||
</div>
|
||||
<div class="meta-row">
|
||||
<span class="badge-news" th:if="${p.category != null}" th:text="${p.category.name}">Category</span>
|
||||
</div>
|
||||
<h3 class="small-title" th:utext="${p.title}">Small Post Title</h3>
|
||||
<span class="post-date" th:if="${(block.data['showDate'] == null or block.data['showDate'] == true) and p.createdDate != null}" th:text="${#temporals.format(p.createdDate, 'dd/MM/yyyy')}">04/08/2026</span>
|
||||
</a>
|
||||
</th:block>
|
||||
</div>
|
||||
</div>
|
||||
@@ -419,11 +419,16 @@ public class PageController {
|
||||
if (editorData.containsKey("blocks")) {
|
||||
blocks = (List<Map<String, Object>>) editorData.get("blocks");
|
||||
for (Map<String, Object> block : blocks) {
|
||||
if ("snippet".equals(block.get("type"))) {
|
||||
if ("snippet".equals(block.get("type")) || "cmsPlugin".equals(block.get("type"))) {
|
||||
Map<String, Object> data = (Map<String, Object>) block.get("data");
|
||||
if (data != null && data.containsKey("id")) {
|
||||
if (data != null) {
|
||||
String snippetId = (String) data.get("id");
|
||||
data.put("htmlContent", snippetService.getSnippetContent(snippetId));
|
||||
if (snippetId == null || snippetId.trim().isEmpty()) {
|
||||
snippetId = (String) data.get("shortcode");
|
||||
}
|
||||
if (snippetId != null && !snippetId.trim().isEmpty()) {
|
||||
data.put("htmlContent", snippetService.getSnippetContent(snippetId));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (block.containsKey("tunes")) {
|
||||
@@ -464,6 +469,7 @@ public class PageController {
|
||||
|
||||
model.addAttribute("page", page);
|
||||
model.addAttribute("blocks", blocks);
|
||||
model.addAttribute("hookManager", hookManager);
|
||||
|
||||
// If the page contains a 'posts' block, load published posts so the template can render them
|
||||
boolean hasPostsBlock = blocks.stream().anyMatch(b -> "posts".equals(b.get("type")));
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
package com.sisvietnamvn.web.plugins.priceTable;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.sisvietnamvn.web.security.AdminContext;
|
||||
import com.sisvietnamvn.web.service.SettingService;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@Controller
|
||||
@RequestMapping("/manage/plugins/price-table")
|
||||
public class priceTableAdminController {
|
||||
|
||||
private final SettingService settingService;
|
||||
private final AdminContext adminContext;
|
||||
private final ObjectMapper objectMapper;
|
||||
private static final String SETTING_KEY = priceTablePlugin.SETTING_KEY;
|
||||
|
||||
public priceTableAdminController(SettingService settingService, AdminContext adminContext, ObjectMapper objectMapper) {
|
||||
this.settingService = settingService;
|
||||
this.adminContext = adminContext;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public String index(Model model) {
|
||||
if (!adminContext.currentUserCan("manage_options")) {
|
||||
return "error/403";
|
||||
}
|
||||
|
||||
String json = settingService.getValue(SETTING_KEY, "[]");
|
||||
List<PriceTable> tables = parseTablesJson(json);
|
||||
|
||||
model.addAttribute("tables", tables);
|
||||
model.addAttribute("tablesJson", json);
|
||||
model.addAttribute("pageTitle", "Quản lý Bảng giá Dịch vụ (Đa bảng giá)");
|
||||
|
||||
return "plugins/price-table/admin-settings";
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public String save(@RequestParam(value = "tableData", required = false) String tableDataJson, RedirectAttributes redirectAttributes) {
|
||||
if (!adminContext.currentUserCan("manage_options")) {
|
||||
return "error/403";
|
||||
}
|
||||
|
||||
if (tableDataJson != null && !tableDataJson.trim().isEmpty()) {
|
||||
try {
|
||||
objectMapper.readValue(tableDataJson, new TypeReference<List<PriceTable>>() {});
|
||||
settingService.setValue(SETTING_KEY, tableDataJson);
|
||||
redirectAttributes.addFlashAttribute("successMessage", "Đã lưu cấu hình bảng giá thành công!");
|
||||
} catch (JsonProcessingException e) {
|
||||
redirectAttributes.addFlashAttribute("errorMessage", "Dữ liệu JSON không hợp lệ: " + e.getMessage());
|
||||
}
|
||||
} else {
|
||||
settingService.setValue(SETTING_KEY, "[]");
|
||||
redirectAttributes.addFlashAttribute("successMessage", "Đã xóa toàn bộ dữ liệu bảng giá!");
|
||||
}
|
||||
|
||||
return "redirect:/manage/plugins/price-table";
|
||||
}
|
||||
|
||||
public List<PriceTable> parseTablesJson(String json) {
|
||||
if (json == null || json.trim().isEmpty() || "[]".equals(json)) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
try {
|
||||
return objectMapper.readValue(json, new TypeReference<List<PriceTable>>() {});
|
||||
} catch (Exception e) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
}
|
||||
|
||||
public static class PriceItem {
|
||||
private String code;
|
||||
private String name;
|
||||
private String unitPrice;
|
||||
private String bhytPrice;
|
||||
private String note;
|
||||
|
||||
public PriceItem() {}
|
||||
public PriceItem(String code, String name, String unitPrice, String bhytPrice, String note) {
|
||||
this.code = code;
|
||||
this.name = name;
|
||||
this.unitPrice = unitPrice;
|
||||
this.bhytPrice = bhytPrice;
|
||||
this.note = note;
|
||||
}
|
||||
|
||||
public String getCode() { return code; }
|
||||
public void setCode(String code) { this.code = code; }
|
||||
public String getName() { return name; }
|
||||
public void setName(String name) { this.name = name; }
|
||||
public String getUnitPrice() { return unitPrice; }
|
||||
public void setUnitPrice(String unitPrice) { this.unitPrice = unitPrice; }
|
||||
public String getBhytPrice() { return bhytPrice; }
|
||||
public void setBhytPrice(String bhytPrice) { this.bhytPrice = bhytPrice; }
|
||||
public String getNote() { return note; }
|
||||
public void setNote(String note) { this.note = note; }
|
||||
}
|
||||
|
||||
public static class PriceCategory {
|
||||
private String categoryName;
|
||||
private List<PriceItem> items = new ArrayList<>();
|
||||
|
||||
public PriceCategory() {}
|
||||
public PriceCategory(String categoryName, List<PriceItem> items) {
|
||||
this.categoryName = categoryName;
|
||||
this.items = items;
|
||||
}
|
||||
|
||||
public String getCategoryName() { return categoryName; }
|
||||
public void setCategoryName(String categoryName) { this.categoryName = categoryName; }
|
||||
public List<PriceItem> getItems() { return items; }
|
||||
public void setItems(List<PriceItem> items) { this.items = items; }
|
||||
}
|
||||
|
||||
public static class PriceTable {
|
||||
private String id;
|
||||
private String name;
|
||||
private String tab1Name;
|
||||
private String tab2Name;
|
||||
private String pdfUrl;
|
||||
private List<PriceCategory> categories = new ArrayList<>();
|
||||
|
||||
public PriceTable() {}
|
||||
public PriceTable(String id, String name, String tab1Name, String tab2Name, String pdfUrl, List<PriceCategory> categories) {
|
||||
this.id = id;
|
||||
this.name = name;
|
||||
this.tab1Name = tab1Name;
|
||||
this.tab2Name = tab2Name;
|
||||
this.pdfUrl = pdfUrl;
|
||||
this.categories = categories;
|
||||
}
|
||||
|
||||
public String getId() { return id; }
|
||||
public void setId(String id) { this.id = id; }
|
||||
public String getName() { return name; }
|
||||
public void setName(String name) { this.name = name; }
|
||||
public String getTab1Name() { return tab1Name; }
|
||||
public void setTab1Name(String tab1Name) { this.tab1Name = tab1Name; }
|
||||
public String getTab2Name() { return tab2Name; }
|
||||
public void setTab2Name(String tab2Name) { this.tab2Name = tab2Name; }
|
||||
public String getPdfUrl() { return pdfUrl; }
|
||||
public void setPdfUrl(String pdfUrl) { this.pdfUrl = pdfUrl; }
|
||||
public List<PriceCategory> getCategories() { return categories; }
|
||||
public void setCategories(List<PriceCategory> categories) { this.categories = categories; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
package com.sisvietnamvn.web.plugins.priceTable;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
||||
import org.springframework.context.event.EventListener;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.sisvietnamvn.web.domain.Plugin;
|
||||
import com.sisvietnamvn.web.domain.PluginStatus;
|
||||
import com.sisvietnamvn.web.hook.HookManager;
|
||||
import com.sisvietnamvn.web.repository.ComponentTemplateRepository;
|
||||
import com.sisvietnamvn.web.repository.HtmlSnippetRepository;
|
||||
import com.sisvietnamvn.web.repository.PluginRepository;
|
||||
import com.sisvietnamvn.web.service.SettingService;
|
||||
|
||||
@Component
|
||||
public class priceTablePlugin {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(priceTablePlugin.class);
|
||||
private static final String PLUGIN_KEY = "price-table-plugin";
|
||||
private static final String SNIPPET_SLUG = "price-table-snippet";
|
||||
public static final String SETTING_KEY = "plugin_price_table_data";
|
||||
|
||||
private final PluginRepository pluginRepository;
|
||||
private final HtmlSnippetRepository snippetRepository;
|
||||
private final ComponentTemplateRepository templateRepository;
|
||||
private final HookManager hookManager;
|
||||
private final SettingService settingService;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public priceTablePlugin(PluginRepository pluginRepository,
|
||||
HtmlSnippetRepository snippetRepository,
|
||||
ComponentTemplateRepository templateRepository,
|
||||
HookManager hookManager,
|
||||
SettingService settingService,
|
||||
ObjectMapper objectMapper) {
|
||||
this.pluginRepository = pluginRepository;
|
||||
this.snippetRepository = snippetRepository;
|
||||
this.templateRepository = templateRepository;
|
||||
this.hookManager = hookManager;
|
||||
this.settingService = settingService;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
@EventListener(ApplicationReadyEvent.class)
|
||||
@Transactional
|
||||
public void onApplicationReady() {
|
||||
boolean isActive = false;
|
||||
try {
|
||||
Plugin p = pluginRepository.findByPluginKey(PLUGIN_KEY).orElseGet(() -> {
|
||||
Plugin newPlugin = new Plugin();
|
||||
newPlugin.setPluginKey(PLUGIN_KEY);
|
||||
newPlugin.setName("Bảng giá Dịch vụ & Kỹ thuật (Đa bảng giá)");
|
||||
newPlugin.setVersion("2.0");
|
||||
newPlugin.setStatus(PluginStatus.ACTIVE);
|
||||
newPlugin.setAuthor("Antigravity");
|
||||
newPlugin.setDescription("Plugin đa bảng giá dịch vụ y tế với hỗ trợ shortcode [plugin:price-table id=\"...\"]");
|
||||
return pluginRepository.save(newPlugin);
|
||||
});
|
||||
isActive = p.getStatus() == PluginStatus.ACTIVE;
|
||||
} catch (Exception e) {
|
||||
LOG.warn("Could not verify priceTablePlugin status: {}", e.getMessage());
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isActive) {
|
||||
LOG.info("priceTablePlugin is inactive.");
|
||||
return;
|
||||
}
|
||||
|
||||
LOG.info("priceTablePlugin is active. Registering hooks...");
|
||||
|
||||
// Hook to add menu to Plugins dropdown
|
||||
hookManager.addFilter("admin_menu_plugins", (content, args) -> {
|
||||
String html = content instanceof String ? (String) content : "";
|
||||
html += "<a class=\"collapse-item\" href=\"/manage/plugins/price-table\">Bảng giá Dịch vụ</a>\n";
|
||||
return html;
|
||||
}, 10);
|
||||
|
||||
// Hook to replace shortcode in content & snippets
|
||||
hookManager.addFilter("the_content", (content, args) -> {
|
||||
String text = content instanceof String ? (String) content : "";
|
||||
return processShortcodes(text);
|
||||
}, 10);
|
||||
|
||||
hookManager.addFilter("snippet_content", (content, args) -> {
|
||||
String text = content instanceof String ? (String) content : "";
|
||||
return processShortcodes(text);
|
||||
}, 10);
|
||||
}
|
||||
|
||||
private String processShortcodes(String text) {
|
||||
if (text == null || !text.contains("[plugin:price-table")) {
|
||||
return text;
|
||||
}
|
||||
|
||||
// Do NOT replace shortcodes inside raw Editor.js JSON strings to prevent JSON syntax corruption
|
||||
String trimmed = text.trim();
|
||||
if (trimmed.startsWith("{") && trimmed.endsWith("}")) {
|
||||
return text;
|
||||
}
|
||||
|
||||
// Pattern matches: [plugin:price-table] or [plugin:price-table id="xyz"] or [plugin:price-table slug="xyz"]
|
||||
Pattern pattern = Pattern.compile("\\[plugin:price-table(?:\\s+(?:id|slug)=\"([^\"]+)\")?\\s*\\]");
|
||||
Matcher matcher = pattern.matcher(text);
|
||||
StringBuilder sb = new StringBuilder();
|
||||
|
||||
while (matcher.find()) {
|
||||
String targetId = matcher.group(1);
|
||||
String replacement = generatePriceTableHtml(targetId);
|
||||
matcher.appendReplacement(sb, Matcher.quoteReplacement(replacement));
|
||||
}
|
||||
matcher.appendTail(sb);
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private String generatePriceTableHtml(String targetId) {
|
||||
String json = settingService.getValue(SETTING_KEY, "[]");
|
||||
List<priceTableAdminController.PriceTable> tables = new ArrayList<>();
|
||||
try {
|
||||
tables = objectMapper.readValue(json, new TypeReference<>() {});
|
||||
} catch (Exception e) {
|
||||
LOG.error("Failed to parse priceTable JSON: {}", e.getMessage());
|
||||
}
|
||||
|
||||
if (tables.isEmpty()) {
|
||||
return "<p class='text-muted text-center py-4'>Chưa có dữ liệu Bảng giá.</p>";
|
||||
}
|
||||
|
||||
// Find requested table or default to first table
|
||||
priceTableAdminController.PriceTable selectedTable = tables.get(0);
|
||||
if (targetId != null && !targetId.trim().isEmpty()) {
|
||||
for (priceTableAdminController.PriceTable t : tables) {
|
||||
if (t.getId() != null && targetId.trim().equalsIgnoreCase(t.getId())) {
|
||||
selectedTable = t;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String tableId = sanitizeId(selectedTable.getId() != null ? selectedTable.getId() : "default");
|
||||
String tab1Name = selectedTable.getTab1Name() != null && !selectedTable.getTab1Name().isEmpty() ? selectedTable.getTab1Name() : "Bảng giá";
|
||||
String tab2Name = selectedTable.getTab2Name() != null && !selectedTable.getTab2Name().isEmpty() ? selectedTable.getTab2Name() : "Danh mục kỹ thuật";
|
||||
String pdfUrl = selectedTable.getPdfUrl() != null ? selectedTable.getPdfUrl().trim() : "";
|
||||
List<priceTableAdminController.PriceCategory> categories = selectedTable.getCategories() != null ? selectedTable.getCategories() : new ArrayList<>();
|
||||
|
||||
StringBuilder html = new StringBuilder();
|
||||
html.append("<style>");
|
||||
html.append(".pt-container-").append(tableId).append(" { max-width: 1140px; margin: 0 auto; padding: 20px 15px; font-family: system-ui, -apple-system, sans-serif; }");
|
||||
html.append(".pt-tabs-").append(tableId).append(" { display: flex; justify-content: center; gap: 40px; border-bottom: 2px solid #e2e8f0; margin-bottom: 28px; }");
|
||||
html.append(".pt-tab-").append(tableId).append(" { font-size: 18px; font-weight: 700; color: #475569; padding: 12px 16px; cursor: pointer; border-bottom: 3px solid transparent; margin-bottom: -2px; transition: all 0.2s; }");
|
||||
html.append(".pt-tab-").append(tableId).append(".active { color: var(--color-old-brick, #9b1c2b); border-bottom-color: var(--color-old-brick, #9b1c2b); }");
|
||||
html.append(".pt-search-bar-").append(tableId).append(" { display: flex; gap: 12px; max-width: 720px; margin: 0 auto 30px auto; }");
|
||||
html.append(".pt-search-input-").append(tableId).append(" { flex: 1; padding: 12px 20px; border: 1px solid #cbd5e1; border-radius: 8px; font-size: 15px; background: #fafafa; outline: none; transition: border 0.2s; }");
|
||||
html.append(".pt-search-input-").append(tableId).append(":focus { border-color: var(--color-old-brick, #9b1c2b); background: #fff; box-shadow: 0 0 0 3px rgba(155, 28, 43, 0.15); }");
|
||||
html.append(".pt-search-btn-").append(tableId).append(" { display: flex; align-items: center; gap: 8px; padding: 12px 24px; background: var(--color-old-brick, #9b1c2b); color: #fff; font-weight: 600; font-size: 15px; border: none; border-radius: 8px; cursor: pointer; transition: background 0.2s; white-space: nowrap; }");
|
||||
html.append(".pt-search-btn-").append(tableId).append(":hover { background: #7a1522; }");
|
||||
html.append(".pt-table-card-").append(tableId).append(" { background: #fff; border-radius: 10px; overflow: hidden; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); }");
|
||||
html.append(".pt-table-header-").append(tableId).append(" { display: grid; grid-template-columns: 2fr 1fr 1fr; background: var(--color-old-brick, #9b1c2b); color: #fff; font-weight: 700; padding: 14px 20px; font-size: 15px; }");
|
||||
html.append(".pt-table-header-").append(tableId).append(" div:nth-child(2), .pt-table-header-").append(tableId).append(" div:nth-child(3) { text-align: center; }");
|
||||
html.append(".pt-cat-row-").append(tableId).append(" { display: flex; align-items: center; justify-content: space-between; background: #fdf2f4; color: var(--color-old-brick, #9b1c2b); font-weight: 700; padding: 14px 20px; font-size: 15px; cursor: pointer; border-bottom: 1px solid #e2e8f0; transition: background 0.2s; user-select: none; }");
|
||||
html.append(".pt-cat-row-").append(tableId).append(":hover { background: #fbe5e9; }");
|
||||
html.append(".pt-cat-arrow-").append(tableId).append(" { transition: transform 0.25s ease; font-size: 14px; }");
|
||||
html.append(".pt-cat-row-").append(tableId).append(".expanded .pt-cat-arrow-").append(tableId).append(" { transform: rotate(180deg); }");
|
||||
html.append(".pt-item-list-").append(tableId).append(" { display: none; }");
|
||||
html.append(".pt-item-list-").append(tableId).append(".open { display: block; }");
|
||||
html.append(".pt-item-row-").append(tableId).append(" { display: grid; grid-template-columns: 2fr 1fr 1fr; padding: 12px 20px 12px 36px; border-bottom: 1px solid #f1f5f9; font-size: 14px; color: #334155; transition: background 0.15s; }");
|
||||
html.append(".pt-item-row-").append(tableId).append(":hover { background: #f8fafc; }");
|
||||
html.append(".pt-item-row-").append(tableId).append(" div:nth-child(2), .pt-item-row-").append(tableId).append(" div:nth-child(3) { text-align: center; font-weight: 500; color: #0f172a; }");
|
||||
html.append(".pt-pdf-wrapper-").append(tableId).append(" { background: #f1f5f9; border-radius: 12px; padding: 20px; text-align: center; box-shadow: 0 4px 6px -1px rgba(0,0,0,0.05); }");
|
||||
html.append(".pt-pdf-iframe-").append(tableId).append(" { width: 100%; height: 850px; border: none; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }");
|
||||
html.append("@media (max-width: 640px) { .pt-table-header-").append(tableId).append(", .pt-item-row-").append(tableId).append(" { grid-template-columns: 1.5fr 1fr 1fr; font-size: 13px; padding-left: 12px; padding-right: 12px; } .pt-cat-row-").append(tableId).append(" { font-size: 13px; padding: 12px; } .pt-pdf-iframe-").append(tableId).append(" { height: 500px; } }");
|
||||
html.append("</style>");
|
||||
|
||||
html.append("<div class='pt-container-").append(tableId).append("'>");
|
||||
html.append(" <div class='pt-tabs-").append(tableId).append("'>");
|
||||
html.append(" <div class='pt-tab-").append(tableId).append(" active' onclick='switchPtTab_").append(tableId).append("(this, 1)'>").append(escapeHtml(tab1Name)).append("</div>");
|
||||
html.append(" <div class='pt-tab-").append(tableId).append("' onclick='switchPtTab_").append(tableId).append("(this, 2)'>").append(escapeHtml(tab2Name)).append("</div>");
|
||||
html.append(" </div>");
|
||||
|
||||
// TAB 1 CONTENT
|
||||
html.append(" <div id='ptTab1_").append(tableId).append("'>");
|
||||
html.append(" <div class='pt-search-bar-").append(tableId).append("'>");
|
||||
html.append(" <input type='text' id='ptSearchInput_").append(tableId).append("' class='pt-search-input-").append(tableId).append("' placeholder='Nhập thông tin cần tìm...' onkeyup='filterPtItems_").append(tableId).append("()'/>");
|
||||
html.append(" <button class='pt-search-btn-").append(tableId).append("' onclick='filterPtItems_").append(tableId).append("()'>");
|
||||
html.append(" <svg width='18' height='18' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><circle cx='11' cy='11' r='8'/><path d='m21 21-4.3-4.3'/></svg>");
|
||||
html.append(" Tra cứu");
|
||||
html.append(" </button>");
|
||||
html.append(" </div>");
|
||||
|
||||
html.append(" <div class='pt-table-card-").append(tableId).append("'>");
|
||||
html.append(" <div class='pt-table-header-").append(tableId).append("'>");
|
||||
html.append(" <div>Tên dịch vụ kỹ thuật</div>");
|
||||
html.append(" <div>Mức thu</div>");
|
||||
html.append(" <div>Mức BHYT</div>");
|
||||
html.append(" </div>");
|
||||
|
||||
html.append(" <div id='ptCategoriesList_").append(tableId).append("'>");
|
||||
for (int cIdx = 0; cIdx < categories.size(); cIdx++) {
|
||||
priceTableAdminController.PriceCategory cat = categories.get(cIdx);
|
||||
String catName = cat.getCategoryName() != null ? cat.getCategoryName() : "";
|
||||
List<priceTableAdminController.PriceItem> items = cat.getItems() != null ? cat.getItems() : new ArrayList<>();
|
||||
|
||||
html.append(" <div class='pt-cat-group-").append(tableId).append("'>");
|
||||
html.append(" <div class='pt-cat-row-").append(tableId).append("' onclick='togglePtCat_").append(tableId).append("(this)'>");
|
||||
html.append(" <div class='pt-cat-title'>").append(escapeHtml(catName)).append("</div>");
|
||||
html.append(" <div class='pt-cat-arrow-").append(tableId).append("'>▼</div>");
|
||||
html.append(" </div>");
|
||||
html.append(" <div class='pt-item-list-").append(tableId).append("'>");
|
||||
for (priceTableAdminController.PriceItem item : items) {
|
||||
String name = item.getName() != null ? item.getName() : "";
|
||||
String uPrice = item.getUnitPrice() != null ? item.getUnitPrice() : "-";
|
||||
String bPrice = item.getBhytPrice() != null ? item.getBhytPrice() : "-";
|
||||
html.append(" <div class='pt-item-row-").append(tableId).append("' data-name='").append(escapeHtml(name.toLowerCase())).append("'>");
|
||||
html.append(" <div>").append(escapeHtml(name)).append("</div>");
|
||||
html.append(" <div>").append(escapeHtml(uPrice)).append("</div>");
|
||||
html.append(" <div>").append(escapeHtml(bPrice)).append("</div>");
|
||||
html.append(" </div>");
|
||||
}
|
||||
html.append(" </div>");
|
||||
html.append(" </div>");
|
||||
}
|
||||
html.append(" </div>");
|
||||
html.append(" </div>");
|
||||
html.append(" </div>"); // END TAB 1
|
||||
|
||||
// TAB 2 CONTENT (PDF Viewer)
|
||||
html.append(" <div id='ptTab2_").append(tableId).append("' style='display: none;'>");
|
||||
html.append(" <div class='pt-pdf-wrapper-").append(tableId).append("'>");
|
||||
if (!pdfUrl.isEmpty()) {
|
||||
html.append(" <iframe class='pt-pdf-iframe-").append(tableId).append("' src='").append(escapeHtml(pdfUrl)).append("#toolbar=1'></iframe>");
|
||||
} else {
|
||||
html.append(" <div class='py-5 text-muted'>");
|
||||
html.append(" <i class='far fa-file-pdf fa-3x mb-3'></i>");
|
||||
html.append(" <p class='m-0'>Chưa cấu hình file PDF cho danh mục kỹ thuật này.</p>");
|
||||
html.append(" </div>");
|
||||
}
|
||||
html.append(" </div>");
|
||||
html.append(" </div>"); // END TAB 2
|
||||
|
||||
html.append("</div>");
|
||||
|
||||
html.append("<script>");
|
||||
html.append("function togglePtCat_").append(tableId).append("(rowEl) {");
|
||||
html.append(" rowEl.classList.toggle('expanded');");
|
||||
html.append(" var listEl = rowEl.nextElementSibling;");
|
||||
html.append(" if(listEl) listEl.classList.toggle('open');");
|
||||
html.append("}");
|
||||
|
||||
html.append("function switchPtTab_").append(tableId).append("(el, tabIndex) {");
|
||||
html.append(" document.querySelectorAll('.pt-tab-").append(tableId).append("').forEach(function(t){ t.classList.remove('active'); });");
|
||||
html.append(" el.classList.add('active');");
|
||||
html.append(" var t1 = document.getElementById('ptTab1_").append(tableId).append("');");
|
||||
html.append(" var t2 = document.getElementById('ptTab2_").append(tableId).append("');");
|
||||
html.append(" if (tabIndex === 1) {");
|
||||
html.append(" if(t1) t1.style.display = 'block';");
|
||||
html.append(" if(t2) t2.style.display = 'none';");
|
||||
html.append(" } else {");
|
||||
html.append(" if(t1) t1.style.display = 'none';");
|
||||
html.append(" if(t2) t2.style.display = 'block';");
|
||||
html.append(" }");
|
||||
html.append("}");
|
||||
|
||||
html.append("function filterPtItems_").append(tableId).append("() {");
|
||||
html.append(" var query = (document.getElementById('ptSearchInput_").append(tableId).append("').value || '').trim().toLowerCase();");
|
||||
html.append(" var groups = document.querySelectorAll('.pt-cat-group-").append(tableId).append("');");
|
||||
html.append(" groups.forEach(function(group) {");
|
||||
html.append(" var items = group.querySelectorAll('.pt-item-row-").append(tableId).append("');");
|
||||
html.append(" var catRow = group.querySelector('.pt-cat-row-").append(tableId).append("');");
|
||||
html.append(" var itemList = group.querySelector('.pt-item-list-").append(tableId).append("');");
|
||||
html.append(" var hasMatch = false;");
|
||||
html.append(" items.forEach(function(item) {");
|
||||
html.append(" var name = item.getAttribute('data-name') || '';");
|
||||
html.append(" if (!query || name.indexOf(query) !== -1) {");
|
||||
html.append(" item.style.display = 'grid';");
|
||||
html.append(" hasMatch = true;");
|
||||
html.append(" } else {");
|
||||
html.append(" item.style.display = 'none';");
|
||||
html.append(" }");
|
||||
html.append(" });");
|
||||
html.append(" if (hasMatch) {");
|
||||
html.append(" group.style.display = 'block';");
|
||||
html.append(" if (query) {");
|
||||
html.append(" catRow.classList.add('expanded');");
|
||||
html.append(" itemList.classList.add('open');");
|
||||
html.append(" }");
|
||||
html.append(" } else {");
|
||||
html.append(" group.style.display = 'none';");
|
||||
html.append(" }");
|
||||
html.append(" });");
|
||||
html.append("}");
|
||||
html.append("</script>");
|
||||
|
||||
return html.toString();
|
||||
}
|
||||
|
||||
private String sanitizeId(String id) {
|
||||
if (id == null) return "default";
|
||||
return id.toLowerCase().replaceAll("[^a-z0-9_-]", "_");
|
||||
}
|
||||
|
||||
private String escapeHtml(String text) {
|
||||
if (text == null) return "";
|
||||
return text.replace("&", "&").replace("<", "<").replace(">", ">").replace("\"", """);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package com.sisvietnamvn.web.plugins.recruitingTable;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.sisvietnamvn.web.security.AdminContext;
|
||||
import com.sisvietnamvn.web.service.SettingService;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@Controller
|
||||
@RequestMapping({"/manage/plugins/recruiting-table", "/manage/plugins/recruitment-table"})
|
||||
public class RecruitingTableAdminController {
|
||||
|
||||
private final SettingService settingService;
|
||||
private final AdminContext adminContext;
|
||||
private final ObjectMapper objectMapper;
|
||||
public static final String SETTING_KEY = RecruitingTablePlugin.SETTING_KEY;
|
||||
|
||||
public RecruitingTableAdminController(SettingService settingService, AdminContext adminContext, ObjectMapper objectMapper) {
|
||||
this.settingService = settingService;
|
||||
this.adminContext = adminContext;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public String index(Model model) {
|
||||
if (!adminContext.currentUserCan("manage_options")) {
|
||||
return "error/403";
|
||||
}
|
||||
|
||||
String json = settingService.getValue(SETTING_KEY, "[]");
|
||||
List<RecruitingTable> tables = parseTablesJson(json);
|
||||
|
||||
model.addAttribute("tables", tables);
|
||||
model.addAttribute("tablesJson", json);
|
||||
model.addAttribute("pageTitle", "Quản lý Bảng Tuyển dụng Nhân sự (Đa danh sách)");
|
||||
|
||||
return "plugins/recruiting-table/admin-settings";
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public String save(@RequestParam(value = "tableData", required = false) String tableDataJson, RedirectAttributes redirectAttributes) {
|
||||
if (!adminContext.currentUserCan("manage_options")) {
|
||||
return "error/403";
|
||||
}
|
||||
|
||||
if (tableDataJson != null && !tableDataJson.trim().isEmpty()) {
|
||||
try {
|
||||
objectMapper.readValue(tableDataJson, new TypeReference<List<RecruitingTable>>() {});
|
||||
settingService.setValue(SETTING_KEY, tableDataJson);
|
||||
redirectAttributes.addFlashAttribute("successMessage", "Đã lưu cấu hình danh sách tuyển dụng thành công!");
|
||||
} catch (JsonProcessingException e) {
|
||||
redirectAttributes.addFlashAttribute("errorMessage", "Dữ liệu JSON không hợp lệ: " + e.getMessage());
|
||||
}
|
||||
} else {
|
||||
settingService.setValue(SETTING_KEY, "[]");
|
||||
redirectAttributes.addFlashAttribute("successMessage", "Đã xóa toàn bộ dữ liệu bảng tuyển dụng!");
|
||||
}
|
||||
|
||||
return "redirect:/manage/plugins/recruiting-table";
|
||||
}
|
||||
|
||||
public List<RecruitingTable> parseTablesJson(String json) {
|
||||
if (json == null || json.trim().isEmpty() || "[]".equals(json)) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
try {
|
||||
return objectMapper.readValue(json, new TypeReference<List<RecruitingTable>>() {});
|
||||
} catch (Exception e) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
}
|
||||
|
||||
public static class RecruitingItem {
|
||||
private String position;
|
||||
private String quantity;
|
||||
private String deadline;
|
||||
private String detailUrl;
|
||||
|
||||
public RecruitingItem() {}
|
||||
public RecruitingItem(String position, String quantity, String deadline, String detailUrl) {
|
||||
this.position = position;
|
||||
this.quantity = quantity;
|
||||
this.deadline = deadline;
|
||||
this.detailUrl = detailUrl;
|
||||
}
|
||||
|
||||
public String getPosition() { return position; }
|
||||
public void setPosition(String position) { this.position = position; }
|
||||
public String getQuantity() { return quantity; }
|
||||
public void setQuantity(String quantity) { this.quantity = quantity; }
|
||||
public String getDeadline() { return deadline; }
|
||||
public void setDeadline(String deadline) { this.deadline = deadline; }
|
||||
public String getDetailUrl() { return detailUrl; }
|
||||
public void setDetailUrl(String detailUrl) { this.detailUrl = detailUrl; }
|
||||
}
|
||||
|
||||
public static class RecruitingTable {
|
||||
private String id;
|
||||
private String name;
|
||||
private String description;
|
||||
private String imageUrl;
|
||||
private List<RecruitingItem> items = new ArrayList<>();
|
||||
|
||||
public RecruitingTable() {}
|
||||
public RecruitingTable(String id, String name, String description, String imageUrl, List<RecruitingItem> items) {
|
||||
this.id = id;
|
||||
this.name = name;
|
||||
this.description = description;
|
||||
this.imageUrl = imageUrl;
|
||||
this.items = items;
|
||||
}
|
||||
|
||||
public String getId() { return id; }
|
||||
public void setId(String id) { this.id = id; }
|
||||
public String getName() { return name; }
|
||||
public void setName(String name) { this.name = name; }
|
||||
public String getDescription() { return description; }
|
||||
public void setDescription(String description) { this.description = description; }
|
||||
public String getImageUrl() { return imageUrl; }
|
||||
public void setImageUrl(String imageUrl) { this.imageUrl = imageUrl; }
|
||||
public List<RecruitingItem> getItems() { return items; }
|
||||
public void setItems(List<RecruitingItem> items) { this.items = items; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
package com.sisvietnamvn.web.plugins.recruitingTable;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
||||
import org.springframework.context.event.EventListener;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.sisvietnamvn.web.domain.Plugin;
|
||||
import com.sisvietnamvn.web.domain.PluginStatus;
|
||||
import com.sisvietnamvn.web.hook.HookManager;
|
||||
import com.sisvietnamvn.web.repository.PluginRepository;
|
||||
import com.sisvietnamvn.web.service.SettingService;
|
||||
|
||||
@Component
|
||||
public class RecruitingTablePlugin {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(RecruitingTablePlugin.class);
|
||||
private static final String PLUGIN_KEY = "recruiting-table-plugin";
|
||||
public static final String SETTING_KEY = "plugin_recruiting_table_data";
|
||||
|
||||
private final PluginRepository pluginRepository;
|
||||
private final HookManager hookManager;
|
||||
private final SettingService settingService;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public RecruitingTablePlugin(PluginRepository pluginRepository,
|
||||
HookManager hookManager,
|
||||
SettingService settingService,
|
||||
ObjectMapper objectMapper) {
|
||||
this.pluginRepository = pluginRepository;
|
||||
this.hookManager = hookManager;
|
||||
this.settingService = settingService;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
@EventListener(ApplicationReadyEvent.class)
|
||||
@Transactional
|
||||
public void onApplicationReady() {
|
||||
boolean isActive = false;
|
||||
try {
|
||||
Plugin p = pluginRepository.findByPluginKey(PLUGIN_KEY).orElseGet(() -> {
|
||||
Plugin newPlugin = new Plugin();
|
||||
newPlugin.setPluginKey(PLUGIN_KEY);
|
||||
newPlugin.setName("Danh sách Tuyển dụng Nhân sự");
|
||||
newPlugin.setVersion("1.0");
|
||||
newPlugin.setStatus(PluginStatus.ACTIVE);
|
||||
newPlugin.setAuthor("Antigravity");
|
||||
newPlugin.setDescription("Plugin danh sách vị trí tuyển dụng nhân sự hỗ trợ shortcode [plugin:recruiting-table id=\"...\"]");
|
||||
return pluginRepository.save(newPlugin);
|
||||
});
|
||||
isActive = p.getStatus() == PluginStatus.ACTIVE;
|
||||
} catch (Exception e) {
|
||||
LOG.warn("Could not verify RecruitingTablePlugin status: {}", e.getMessage());
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isActive) {
|
||||
LOG.info("RecruitingTablePlugin is inactive.");
|
||||
return;
|
||||
}
|
||||
|
||||
LOG.info("RecruitingTablePlugin is active. Registering hooks...");
|
||||
|
||||
// Hook to add menu item in Admin Plugins dropdown
|
||||
hookManager.addFilter("admin_menu_plugins", (content, args) -> {
|
||||
String html = content instanceof String ? (String) content : "";
|
||||
html += "<a class=\"collapse-item\" href=\"/manage/plugins/recruiting-table\">Tuyển dụng Nhân sự</a>\n";
|
||||
return html;
|
||||
}, 10);
|
||||
|
||||
// Hook to replace shortcodes in page content & snippets
|
||||
hookManager.addFilter("the_content", (content, args) -> {
|
||||
String text = content instanceof String ? (String) content : "";
|
||||
return processShortcodes(text);
|
||||
}, 10);
|
||||
|
||||
hookManager.addFilter("snippet_content", (content, args) -> {
|
||||
String text = content instanceof String ? (String) content : "";
|
||||
return processShortcodes(text);
|
||||
}, 10);
|
||||
|
||||
// Seed initial default recruiting list if database setting is empty
|
||||
seedDefaultData();
|
||||
}
|
||||
|
||||
private void seedDefaultData() {
|
||||
String existing = settingService.getValue(SETTING_KEY, null);
|
||||
if (existing == null || existing.trim().isEmpty() || "[]".equals(existing.trim())) {
|
||||
List<RecruitingTableAdminController.RecruitingTable> defaultList = new ArrayList<>();
|
||||
|
||||
List<RecruitingTableAdminController.RecruitingItem> items = new ArrayList<>();
|
||||
items.add(new RecruitingTableAdminController.RecruitingItem("Bác sĩ Cấp cứu", "01", "31/08/2026", "#"));
|
||||
items.add(new RecruitingTableAdminController.RecruitingItem("Bác sĩ Huyết học", "01", "30/06/2026", "#"));
|
||||
items.add(new RecruitingTableAdminController.RecruitingItem("Bác sĩ Xét nghiệm", "01", "30/06/2026", "#"));
|
||||
items.add(new RecruitingTableAdminController.RecruitingItem("Bác sĩ Vi sinh", "01", "30/06/2026", "#"));
|
||||
|
||||
RecruitingTableAdminController.RecruitingTable defaultTable = new RecruitingTableAdminController.RecruitingTable(
|
||||
"recruiting_1",
|
||||
"Danh sách vị trí tuyển dụng Bác sĩ",
|
||||
"Danh sách nhu cầu tuyển dụng các vị trí Bác sĩ chuyên khoa tại bệnh viện S.I.S Cần Thơ",
|
||||
null,
|
||||
items
|
||||
);
|
||||
defaultList.add(defaultTable);
|
||||
|
||||
try {
|
||||
settingService.setValue(SETTING_KEY, objectMapper.writeValueAsString(defaultList));
|
||||
LOG.info("Seeded default recruiting table data.");
|
||||
} catch (Exception e) {
|
||||
LOG.error("Failed to seed default recruiting table data: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private String processShortcodes(String text) {
|
||||
if (text == null || (!text.contains("[plugin:recruiting-table") && !text.contains("[plugin:recruitment-table") && !text.contains("[plugin:price-table"))) {
|
||||
return text;
|
||||
}
|
||||
|
||||
// Do NOT replace shortcodes inside raw Editor.js JSON strings
|
||||
String trimmed = text.trim();
|
||||
if (trimmed.startsWith("{") && trimmed.endsWith("}")) {
|
||||
return text;
|
||||
}
|
||||
|
||||
// Matches: [plugin:recruiting-table] or [plugin:recruitment-table] or [plugin:price-table] (with optional id="..." or slug="...")
|
||||
Pattern pattern = Pattern.compile("\\[plugin:(?:recruiting-table|recruitment-table|price-table)(?:\\s+(?:id|slug)=\"([^\"]+)\")?\\s*\\]");
|
||||
Matcher matcher = pattern.matcher(text);
|
||||
StringBuilder sb = new StringBuilder();
|
||||
|
||||
while (matcher.find()) {
|
||||
String targetId = matcher.group(1);
|
||||
String replacement = generateRecruitingTableHtml(targetId);
|
||||
matcher.appendReplacement(sb, Matcher.quoteReplacement(replacement));
|
||||
}
|
||||
matcher.appendTail(sb);
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
public String generateRecruitingTableHtml(String targetId) {
|
||||
String json = settingService.getValue(SETTING_KEY, "[]");
|
||||
List<RecruitingTableAdminController.RecruitingTable> tables = new ArrayList<>();
|
||||
try {
|
||||
tables = objectMapper.readValue(json, new TypeReference<>() {});
|
||||
} catch (Exception e) {
|
||||
LOG.error("Failed to parse recruiting table JSON: {}", e.getMessage());
|
||||
}
|
||||
|
||||
if (tables.isEmpty()) {
|
||||
return "<div class='alert alert-light text-center py-4 border rounded'>Chưa có thông tin tuyển dụng.</div>";
|
||||
}
|
||||
|
||||
// Find requested table or default to first
|
||||
RecruitingTableAdminController.RecruitingTable selectedTable = tables.get(0);
|
||||
if (targetId != null && !targetId.trim().isEmpty()) {
|
||||
for (RecruitingTableAdminController.RecruitingTable t : tables) {
|
||||
if (t.getId() != null && targetId.trim().equalsIgnoreCase(t.getId())) {
|
||||
selectedTable = t;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String tableId = sanitizeId(selectedTable.getId() != null ? selectedTable.getId() : "default");
|
||||
String imageUrl = selectedTable.getImageUrl();
|
||||
List<RecruitingTableAdminController.RecruitingItem> items = selectedTable.getItems() != null ? selectedTable.getItems() : new ArrayList<>();
|
||||
|
||||
StringBuilder html = new StringBuilder();
|
||||
html.append("<style>");
|
||||
html.append(".sis-recruiting-wrap-").append(tableId).append(" { font-family: inherit; width: 100% !important; max-width: 100% !important; margin: 1rem 0; display: block; }");
|
||||
html.append(".sis-recruiting-layout-").append(tableId).append(" { display: flex; flex-wrap: wrap; gap: 2rem; align-items: flex-start; width: 100%; }");
|
||||
html.append(".sis-recruiting-img-col-").append(tableId).append(" { flex: 0 0 320px; max-width: 380px; width: 100%; }");
|
||||
html.append(".sis-recruiting-img-col-").append(tableId).append(" img { width: 100%; height: auto; border-radius: 10px; box-shadow: 0 4px 15px rgba(0,0,0,0.08); object-fit: cover; }");
|
||||
html.append(".sis-recruiting-table-col-").append(tableId).append(" { flex: 1 1 450px; width: 100%; min-width: 0; }");
|
||||
html.append(".sis-recruiting-table-wrap-").append(tableId).append(" { width: 100% !important; max-width: 100% !important; display: block; overflow-x: auto; -webkit-overflow-scrolling: touch; }");
|
||||
html.append(".sis-recruiting-table-").append(tableId).append(" { width: 100% !important; min-width: 580px; border-collapse: separate; border-spacing: 0; table-layout: auto; }");
|
||||
html.append(".sis-recruiting-table-").append(tableId).append(" th { font-weight: 700; color: #334155; font-size: 0.95rem; border-bottom: 2px solid #e2e8f0; padding: 12px 14px; background: transparent; white-space: nowrap !important; }");
|
||||
html.append(".sis-recruiting-table-").append(tableId).append(" td { padding: 14px 14px; border-bottom: 1px solid #f1f5f9; vertical-align: middle; font-size: 0.95rem; white-space: nowrap !important; }");
|
||||
html.append(".sis-recruiting-table-").append(tableId).append(" tr:last-child td { border-bottom: none; }");
|
||||
html.append(".sis-recruiting-title-link { color: #007bff; font-weight: 700; text-decoration: none; font-size: 1.02rem; transition: color 0.2s; white-space: nowrap !important; }");
|
||||
html.append(".sis-recruiting-title-link:hover { color: #0056b3; text-decoration: underline; }");
|
||||
html.append(".sis-recruiting-detail-btn { color: #007bff; font-weight: 600; text-decoration: none; font-size: 0.9rem; display: inline-flex; align-items: center; transition: all 0.2s; white-space: nowrap !important; }");
|
||||
html.append(".sis-recruiting-detail-btn:hover { color: #0056b3; transform: translateX(3px); }");
|
||||
html.append(".sis-recruiting-header-icon { font-size: 0.75rem; margin-left: 4px; color: #007bff; }");
|
||||
html.append("@media (max-width: 768px) { .sis-recruiting-table-").append(tableId).append(" th, .sis-recruiting-table-").append(tableId).append(" td { padding: 10px 8px; font-size: 0.85rem; white-space: nowrap !important; } }");
|
||||
html.append("</style>");
|
||||
|
||||
html.append("<div class='sis-recruiting-wrap-").append(tableId).append("'>");
|
||||
if (imageUrl != null && !imageUrl.trim().isEmpty()) {
|
||||
html.append(" <div class='sis-recruiting-layout-").append(tableId).append("'>");
|
||||
html.append(" <div class='sis-recruiting-img-col-").append(tableId).append("'>");
|
||||
html.append(" <img src='").append(escapeHtml(imageUrl.trim())).append("' alt='").append(escapeHtml(selectedTable.getName())).append("' />");
|
||||
html.append(" </div>");
|
||||
html.append(" <div class='sis-recruiting-table-col-").append(tableId).append("'>");
|
||||
}
|
||||
html.append(" <div class='sis-recruiting-table-wrap-").append(tableId).append("'>");
|
||||
html.append(" <table class='sis-recruiting-table-").append(tableId).append("'>");
|
||||
html.append(" <thead>");
|
||||
html.append(" <tr>");
|
||||
html.append(" <th style='text-align: left; width: 100% !important; white-space: nowrap !important;'>Vị trí tuyển dụng <span class='sis-recruiting-header-icon'>˅</span></th>");
|
||||
html.append(" <th style='text-align: center; white-space: nowrap !important;'>Số lượng <span class='sis-recruiting-header-icon'>˅</span></th>");
|
||||
html.append(" <th style='text-align: center; white-space: nowrap !important;'>Thời hạn <span class='sis-recruiting-header-icon'>˅</span></th>");
|
||||
html.append(" <th style='text-align: right; white-space: nowrap !important;'></th>");
|
||||
html.append(" </tr>");
|
||||
html.append(" </thead>");
|
||||
html.append(" <tbody>");
|
||||
|
||||
if (items.isEmpty()) {
|
||||
html.append(" <tr><td colspan='4' class='text-center text-muted py-4'>Hiện chưa có vị trí tuyển dụng nào.</td></tr>");
|
||||
} else {
|
||||
for (RecruitingTableAdminController.RecruitingItem item : items) {
|
||||
String pos = item.getPosition() != null ? item.getPosition() : "";
|
||||
String qty = item.getQuantity() != null ? item.getQuantity() : "01";
|
||||
String dl = item.getDeadline() != null ? item.getDeadline() : "";
|
||||
String url = item.getDetailUrl() != null && !item.getDetailUrl().trim().isEmpty() ? item.getDetailUrl().trim() : "#";
|
||||
|
||||
html.append(" <tr>");
|
||||
html.append(" <td style='text-align: left; width: 100% !important; white-space: nowrap !important;'>");
|
||||
String targetUrl = (!"#".equals(url) && !url.isEmpty()) ? url : "javascript:void(0);";
|
||||
html.append(" <a href='").append(escapeHtml(targetUrl)).append("' class='sis-recruiting-title-link' style='white-space: nowrap !important; display: inline-block;'>").append(escapeHtml(pos)).append("</a>");
|
||||
html.append(" </td>");
|
||||
html.append(" <td style='text-align: center; color: #6c757d; font-weight: 600; white-space: nowrap !important;'>").append(escapeHtml(qty)).append("</td>");
|
||||
html.append(" <td style='text-align: center; color: #6c757d; font-weight: 600; white-space: nowrap !important;'>").append(escapeHtml(dl)).append("</td>");
|
||||
html.append(" <td style='text-align: right; white-space: nowrap !important;'>");
|
||||
if (!"#".equals(url)) {
|
||||
html.append(" <a href='").append(escapeHtml(url)).append("' class='sis-recruiting-detail-btn' style='white-space: nowrap !important;'>Xem chi tiết →</a>");
|
||||
} else {
|
||||
html.append(" <span class='sis-recruiting-detail-btn' style='opacity:0.6;cursor:default; white-space: nowrap !important;'>Xem chi tiết →</span>");
|
||||
}
|
||||
html.append(" </td>");
|
||||
html.append(" </tr>");
|
||||
}
|
||||
}
|
||||
|
||||
html.append(" </tbody>");
|
||||
html.append(" </table>");
|
||||
html.append(" </div>");
|
||||
|
||||
if (imageUrl != null && !imageUrl.trim().isEmpty()) {
|
||||
html.append(" </div>"); // end table-col
|
||||
html.append(" </div>"); // end layout
|
||||
}
|
||||
|
||||
html.append("</div>");
|
||||
|
||||
return html.toString();
|
||||
}
|
||||
|
||||
private String sanitizeId(String id) {
|
||||
if (id == null) return "default";
|
||||
return id.toLowerCase().replaceAll("[^a-z0-9_-]", "_");
|
||||
}
|
||||
|
||||
private String escapeHtml(String text) {
|
||||
if (text == null) return "";
|
||||
return text.replace("&", "&").replace("<", "<").replace(">", ">").replace("\"", """);
|
||||
}
|
||||
}
|
||||
@@ -1,71 +1,74 @@
|
||||
package com.sisvietnamvn.web.service;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.sisvietnamvn.web.domain.HtmlSnippet;
|
||||
import com.sisvietnamvn.web.domain.MenuItem;
|
||||
import com.sisvietnamvn.web.hook.HookManager;
|
||||
import com.sisvietnamvn.web.repository.HtmlSnippetRepository;
|
||||
import com.sisvietnamvn.web.service.dto.WidgetDto;
|
||||
import com.sisvietnamvn.web.repository.MenuItemRepository;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Service for managing HtmlSnippets and providing them to Thymeleaf.
|
||||
*/
|
||||
@Service("snippetService") // Named bean so it can be called from Thymeleaf via @snippetService
|
||||
@Transactional
|
||||
@Service("snippetService")
|
||||
public class HtmlSnippetService {
|
||||
|
||||
private final HtmlSnippetRepository snippetRepository;
|
||||
private final SettingService settingService;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final ComponentTemplateService componentTemplateService;
|
||||
private final com.sisvietnamvn.web.hook.HookManager hookManager;
|
||||
private static final Logger LOG = LoggerFactory.getLogger(HtmlSnippetService.class);
|
||||
|
||||
public HtmlSnippetService(HtmlSnippetRepository snippetRepository, SettingService settingService, ObjectMapper objectMapper, ComponentTemplateService componentTemplateService, com.sisvietnamvn.web.hook.HookManager hookManager) {
|
||||
private final HtmlSnippetRepository snippetRepository;
|
||||
private final MenuItemRepository menuItemRepository;
|
||||
private final ComponentTemplateService componentTemplateService;
|
||||
private final HookManager hookManager;
|
||||
|
||||
public HtmlSnippetService(HtmlSnippetRepository snippetRepository,
|
||||
MenuItemRepository menuItemRepository,
|
||||
ComponentTemplateService componentTemplateService,
|
||||
HookManager hookManager) {
|
||||
this.snippetRepository = snippetRepository;
|
||||
this.settingService = settingService;
|
||||
this.objectMapper = objectMapper;
|
||||
this.menuItemRepository = menuItemRepository;
|
||||
this.componentTemplateService = componentTemplateService;
|
||||
this.hookManager = hookManager;
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<HtmlSnippet> findAll() {
|
||||
return snippetRepository.findAll();
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public Optional<HtmlSnippet> findById(Long id) {
|
||||
return snippetRepository.findById(id);
|
||||
}
|
||||
|
||||
public HtmlSnippet save(HtmlSnippet snippet) {
|
||||
return snippetRepository.save(snippet);
|
||||
}
|
||||
|
||||
public void deleteById(Long id) {
|
||||
snippetRepository.deleteById(id);
|
||||
@Transactional(readOnly = true)
|
||||
public Optional<HtmlSnippet> findBySlug(String slug) {
|
||||
return snippetRepository.findBySlug(slug);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the content of a snippet by slug.
|
||||
* If the snippet is not found or is inactive, returns an empty string.
|
||||
* Can be called in Thymeleaf using: ${@snippetService.getSnippetContent('slug')}
|
||||
*
|
||||
* @param slug the unique slug of the snippet
|
||||
* @return the HTML content or empty string
|
||||
* Gets the content of a snippet by slug or direct shortcode.
|
||||
* If the snippet is not found by slug in DB, but the input looks like a shortcode,
|
||||
* it processes the shortcode directly.
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public String getSnippetContent(String slug) {
|
||||
if (slug == null || slug.isEmpty()) {
|
||||
if (slug == null || slug.trim().isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
String content = snippetRepository.findBySlug(slug)
|
||||
String trimmedSlug = slug.trim();
|
||||
String content = snippetRepository.findBySlug(trimmedSlug)
|
||||
.filter(HtmlSnippet::isActive)
|
||||
.map(HtmlSnippet::getContent)
|
||||
.orElse("");
|
||||
|
||||
|
||||
// If no snippet found in DB, check if the input is a direct shortcode
|
||||
if (content.isEmpty() && (trimmedSlug.contains("[") || trimmedSlug.contains("plugin:"))) {
|
||||
content = trimmedSlug;
|
||||
}
|
||||
|
||||
if (content.isEmpty()) return content;
|
||||
|
||||
if (content.contains("[widgets:sidebar]")) {
|
||||
@@ -79,7 +82,6 @@ public class HtmlSnippetService {
|
||||
}
|
||||
|
||||
// Process shortcodes for menus, e.g., [menu:INFO] or [menu:INFO:dropdown]
|
||||
// Support HTML entities [ and ] in case the WYSIWYG editor escaped them
|
||||
java.util.regex.Matcher m = java.util.regex.Pattern.compile("(?:\\[|[)menu:([a-zA-Z0-9_-]+)(?::([a-zA-Z0-9_-]+))?(?:\\]|])").matcher(content);
|
||||
StringBuffer sb = new StringBuffer();
|
||||
while (m.find()) {
|
||||
@@ -90,10 +92,7 @@ public class HtmlSnippetService {
|
||||
m.appendTail(sb);
|
||||
content = sb.toString();
|
||||
|
||||
// Process component template shortcodes:
|
||||
// [component:SLUG]
|
||||
// [component:SLUG data-source="menu:CTA"]
|
||||
// Also supports HTML-escaped brackets
|
||||
// Process component template shortcodes: [component:SLUG]
|
||||
java.util.regex.Matcher cm = java.util.regex.Pattern.compile(
|
||||
"(?:\\[|[)component:([a-zA-Z0-9_-]+)(?:\\s+data-source=\"([^\"]+)\")?(?:\\]|])"
|
||||
).matcher(content);
|
||||
@@ -109,7 +108,7 @@ public class HtmlSnippetService {
|
||||
|
||||
content = (String) hookManager.applyFilters("snippet_content", content);
|
||||
|
||||
return "\n<!-- BEGIN Snippet: " + slug + " -->\n" + content + "\n<!-- END Snippet: " + slug + " -->\n";
|
||||
return "\n<!-- BEGIN Snippet: " + trimmedSlug + " -->\n" + content + "\n<!-- END Snippet: " + trimmedSlug + " -->\n";
|
||||
}
|
||||
|
||||
private String renderMenu(String location, String style) {
|
||||
@@ -118,21 +117,16 @@ public class HtmlSnippetService {
|
||||
}
|
||||
|
||||
private String renderWidgets(String area) {
|
||||
String json = settingService.getValue("theme_widgets_" + area, "[]");
|
||||
try {
|
||||
List<WidgetDto> widgets = objectMapper.readValue(json, new TypeReference<List<WidgetDto>>() {});
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (WidgetDto w : widgets) {
|
||||
sb.append("<div class=\"card shadow-sm mb-4\">");
|
||||
if (w.getTitle() != null && !w.getTitle().isEmpty()) {
|
||||
sb.append("<div class=\"card-header bg-white font-weight-bold\">").append(w.getTitle()).append("</div>");
|
||||
}
|
||||
sb.append("<div class=\"card-body\">").append(w.getContent()).append("</div>");
|
||||
sb.append("</div>");
|
||||
}
|
||||
return sb.toString();
|
||||
} catch (Exception e) {
|
||||
return "";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public HtmlSnippet save(HtmlSnippet snippet) {
|
||||
return snippetRepository.save(snippet);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void deleteById(Long id) {
|
||||
snippetRepository.deleteById(id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -255,7 +255,7 @@ i,
|
||||
}
|
||||
|
||||
.tabs-container {
|
||||
background-color: var(--color-old-brick);
|
||||
background-color: white;
|
||||
border-radius: 8px;
|
||||
padding: 4px;
|
||||
display: flex;
|
||||
@@ -263,13 +263,18 @@ i,
|
||||
width: 100%;
|
||||
max-width: 860px;
|
||||
margin-bottom: calc(var(--spacing) * 8);
|
||||
color: var(--color-old-brick);
|
||||
.active {
|
||||
background-color: var(--color-old-brick);
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
.custom-tab-btn {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
background-color: transparent;
|
||||
color: white;
|
||||
color: var(--color-old-brick);
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
transition: all 0.2s ease-in-out;
|
||||
@@ -280,10 +285,10 @@ i,
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.custom-tab-btn:hover {
|
||||
/* .custom-tab-btn:hover {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
background-color: transparent;
|
||||
}
|
||||
} */
|
||||
|
||||
/* --- Utility Classes --- */
|
||||
.mb-4 {
|
||||
@@ -581,6 +586,34 @@ figure.table table tr:hover {
|
||||
box-shadow: 0 0 30px #0000001a;
|
||||
}
|
||||
|
||||
.sis-grid-image-wrapper img.rounded {
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.align-content-center {
|
||||
align-content: center;
|
||||
}
|
||||
|
||||
#wrapperCrowdDoctors .sis-grid-image-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.recruit-hero-banner #crowdDoctors {
|
||||
position: absolute;
|
||||
/* top: 50%; */
|
||||
/* left: 50%; */
|
||||
width: 640px;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.hover-gia-tri-cot-loi {
|
||||
height: 432px;
|
||||
}
|
||||
|
||||
.wrapper-gia-tri-cot-loi {
|
||||
background-color: #f0aeae;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
5. Responsive Breakpoints — 3 Groups Only
|
||||
========================================================================== */
|
||||
@@ -779,6 +812,47 @@ figure.table table tr:hover {
|
||||
border-top: 2px solid #881c1c;
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
#wrapperCrowdDoctors {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* --- Responsive Tab Plugin (Switch to Select / Dropdown on Mobile & Tablet <= 1024px) --- */
|
||||
@media screen and (max-width: 1024px) {
|
||||
.sis-tab-group-wrapper .tabs-container {
|
||||
display: none !important;
|
||||
}
|
||||
.sis-tab-select-wrapper {
|
||||
display: block !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1025px) {
|
||||
.sis-tab-select-wrapper {
|
||||
display: none !important;
|
||||
}
|
||||
.sis-tab-group-wrapper .tabs-container {
|
||||
display: flex !important;
|
||||
}
|
||||
}
|
||||
|
||||
.custom-tab-select {
|
||||
border: 2px solid var(--color-old-brick, #9b1c2b) !important;
|
||||
color: var(--color-old-brick, #9b1c2b) !important;
|
||||
font-weight: 700 !important;
|
||||
font-size: 1rem !important;
|
||||
border-radius: 8px !important;
|
||||
padding: 10px 15px !important;
|
||||
background-color: #fff !important;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.06) !important;
|
||||
width: 100% !important;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.custom-tab-select:focus {
|
||||
outline: none !important;
|
||||
box-shadow: 0 0 0 3px rgba(155, 28, 43, 0.25) !important;
|
||||
}
|
||||
|
||||
/* --- TABLET ONLY (769px – 1024px) --- */
|
||||
@@ -1046,6 +1120,14 @@ figure.table table tr:hover {
|
||||
.umass-platform-homepage .header-hospital-name {
|
||||
display: block;
|
||||
}
|
||||
|
||||
#wrapperCrowdDoctors {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.hover-gia-tri-cot-loi {
|
||||
height: 600px;
|
||||
}
|
||||
}
|
||||
|
||||
/* --- DESKTOP ONLY (≥ 1025px) --- */
|
||||
@@ -1737,6 +1819,137 @@ Large Desktop (@media (min-width: 1366px)): Lines 1421 - 1481. */
|
||||
.sis-hover-card-v2 .sis-hover-card__title {
|
||||
font-size: 1.05rem !important;
|
||||
}
|
||||
|
||||
.hover-gia-tri-cot-loi {
|
||||
height: 850px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Hover Cards Variant 3 (Hover 3 — Horizontal Layout)
|
||||
========================================================================== */
|
||||
.sis-hover-card-v3-section {
|
||||
padding-top: 30px;
|
||||
padding-bottom: 30px;
|
||||
}
|
||||
|
||||
.sis-hover-card-v3-section .active img.sis-hover-card__img-main {
|
||||
top: 0px !important;
|
||||
left: 0px !important;
|
||||
}
|
||||
|
||||
.sis-hover-card-v3 {
|
||||
background-color: #ffffff;
|
||||
border: 1px solid #e6f0fa;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 15px rgba(0, 37, 84, 0.06);
|
||||
padding: 24px;
|
||||
display: flex !important;
|
||||
flex-direction: row !important;
|
||||
align-items: center !important;
|
||||
justify-content: flex-start !important;
|
||||
text-align: left !important;
|
||||
cursor: pointer;
|
||||
transition: all 0.35s cubic-bezier(0.25, 0.8, 0.25, 1);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.sis-hover-card-v3 .sis-hover-card__icon-wrapper {
|
||||
margin-right: 20px !important;
|
||||
flex-shrink: 0 !important;
|
||||
}
|
||||
|
||||
.sis-hover-card-v3 .sis-hover-card__icon {
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
height: 70px !important;
|
||||
width: 70px !important;
|
||||
position: relative !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
.sis-hover-card-v3 .sis-hover-card__icon img {
|
||||
height: 60px !important;
|
||||
width: auto !important;
|
||||
max-width: 100% !important;
|
||||
object-fit: contain !important;
|
||||
transition: all 0.35s cubic-bezier(0.25, 0.8, 0.25, 1) !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
.sis-hover-card-v3 .sis-hover-card__content {
|
||||
display: flex !important;
|
||||
flex-direction: column !important;
|
||||
justify-content: center !important;
|
||||
flex-grow: 1 !important;
|
||||
}
|
||||
|
||||
.sis-hover-card-v3 .sis-hover-card__title {
|
||||
color: #0d71ba !important;
|
||||
font-size: 1.25rem !important;
|
||||
font-weight: 700 !important;
|
||||
margin-bottom: 8px !important;
|
||||
transition: color 0.35s ease !important;
|
||||
}
|
||||
|
||||
.sis-hover-card-v3 .sis-hover-card__desc {
|
||||
color: #555555 !important;
|
||||
font-size: 0.95rem !important;
|
||||
line-height: 1.5 !important;
|
||||
margin: 0 !important;
|
||||
transition: color 0.35s ease !important;
|
||||
opacity: 1 !important;
|
||||
max-height: none !important;
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
/* Hover 3 Active / Hover State */
|
||||
.sis-hover-card-v3:hover,
|
||||
.sis-hover-card-v3.active {
|
||||
background-color: #0d71ba !important;
|
||||
border-color: #0d71ba !important;
|
||||
transform: translateY(-4px) !important;
|
||||
box-shadow: 0 12px 28px rgba(13, 113, 186, 0.28) !important;
|
||||
}
|
||||
|
||||
.sis-hover-card-v3:hover .sis-hover-card__title,
|
||||
.sis-hover-card-v3.active .sis-hover-card__title,
|
||||
.sis-hover-card-v3:hover .sis-hover-card__desc,
|
||||
.sis-hover-card-v3.active .sis-hover-card__desc {
|
||||
color: #ffffff !important;
|
||||
}
|
||||
|
||||
/* Hover 3 Image Swapping */
|
||||
.sis-hover-card-v3:hover .sis-hover-card__img-main:has(~ .sis-hover-card__img-hover),
|
||||
.sis-hover-card-v3.active .sis-hover-card__img-main:has(~ .sis-hover-card__img-hover) {
|
||||
opacity: 0 !important;
|
||||
}
|
||||
|
||||
.sis-hover-card-v3:hover .sis-hover-card__img-hover,
|
||||
.sis-hover-card-v3.active .sis-hover-card__img-hover {
|
||||
opacity: 1 !important;
|
||||
transform: scale(1.1) !important;
|
||||
}
|
||||
|
||||
/* Fallback image filter if hover image is NOT present */
|
||||
.sis-hover-card-v3:hover .sis-hover-card__icon img:not(.sis-hover-card__img-hover):not(:has(~ .sis-hover-card__img-hover)),
|
||||
.sis-hover-card-v3.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: scale(1.1) !important;
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
.sis-hover-card-v3 {
|
||||
flex-direction: column !important;
|
||||
text-align: center !important;
|
||||
}
|
||||
.sis-hover-card-v3 .sis-hover-card__icon-wrapper {
|
||||
margin-right: 0 !important;
|
||||
margin-bottom: 16px !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
@@ -1755,8 +1968,8 @@ Large Desktop (@media (min-width: 1366px)): Lines 1421 - 1481. */
|
||||
flex-wrap: nowrap !important;
|
||||
justify-content: center !important;
|
||||
}
|
||||
.sis-grid-layout.justify-content-center > [class*="sis-grid-span-"],
|
||||
.sis-grid-layout.justify-content-center > [class*="col-"] {
|
||||
.sis-grid-layout.justify-content-center > [class*='sis-grid-span-'],
|
||||
.sis-grid-layout.justify-content-center > [class*='col-'] {
|
||||
flex: 0 0 auto !important;
|
||||
width: auto !important;
|
||||
max-width: none !important;
|
||||
@@ -1819,17 +2032,138 @@ Large Desktop (@media (min-width: 1366px)): Lines 1421 - 1481. */
|
||||
|
||||
/* Tablet & Desktop grid spans */
|
||||
@media (min-width: 768px) {
|
||||
.sis-grid-span-1 { grid-column: span 1 !important; }
|
||||
.sis-grid-span-2 { grid-column: span 2 !important; }
|
||||
.sis-grid-span-3 { grid-column: span 3 !important; }
|
||||
.sis-grid-span-4 { grid-column: span 4 !important; }
|
||||
.sis-grid-span-5 { grid-column: span 5 !important; }
|
||||
.sis-grid-span-6 { grid-column: span 6 !important; }
|
||||
.sis-grid-span-7 { grid-column: span 7 !important; }
|
||||
.sis-grid-span-8 { grid-column: span 8 !important; }
|
||||
.sis-grid-span-9 { grid-column: span 9 !important; }
|
||||
.sis-grid-span-10 { grid-column: span 10 !important; }
|
||||
.sis-grid-span-11 { grid-column: span 11 !important; }
|
||||
.sis-grid-span-12 { grid-column: span 12 !important; }
|
||||
.sis-grid-span-1 {
|
||||
grid-column: span 1 !important;
|
||||
}
|
||||
.sis-grid-span-2 {
|
||||
grid-column: span 2 !important;
|
||||
}
|
||||
.sis-grid-span-3 {
|
||||
grid-column: span 3 !important;
|
||||
}
|
||||
.sis-grid-span-4 {
|
||||
grid-column: span 4 !important;
|
||||
}
|
||||
.sis-grid-span-5 {
|
||||
grid-column: span 5 !important;
|
||||
}
|
||||
.sis-grid-span-6 {
|
||||
grid-column: span 6 !important;
|
||||
}
|
||||
.sis-grid-span-7 {
|
||||
grid-column: span 7 !important;
|
||||
}
|
||||
.sis-grid-span-8 {
|
||||
grid-column: span 8 !important;
|
||||
}
|
||||
.sis-grid-span-9 {
|
||||
grid-column: span 9 !important;
|
||||
}
|
||||
.sis-grid-span-10 {
|
||||
grid-column: span 10 !important;
|
||||
}
|
||||
.sis-grid-span-11 {
|
||||
grid-column: span 11 !important;
|
||||
}
|
||||
.sis-grid-span-12 {
|
||||
grid-column: span 12 !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===================================================
|
||||
Preserve explicit width/height on images (TinyMCE icons, trophies, etc.)
|
||||
Overrides global theme img { width: 100% } AND parent wrapper classes (size-5, md:size-8, .relative).
|
||||
=================================================== */
|
||||
[class*='size-'] img[width],
|
||||
.relative img[width],
|
||||
img[width]:not([width='100%']):not([width='100']):not([class*='img-fluid']):not([class*='w-100']) {
|
||||
width: revert !important;
|
||||
height: auto !important;
|
||||
inline-size: auto !important;
|
||||
block-size: auto !important;
|
||||
max-width: 100% !important;
|
||||
}
|
||||
|
||||
img[height]:not([height='100%']):not([height='100']):not([class*='img-fluid']):not([class*='w-100']) {
|
||||
height: revert !important;
|
||||
}
|
||||
|
||||
img[style*='width']:not([style*='width: 100%']):not([style*='width:100%']):not([style*='width: 100vw']) {
|
||||
max-width: 100%;
|
||||
display: inline-block !important;
|
||||
}
|
||||
|
||||
/* Explicit pixel attribute mapping for images inside constrained wrapper containers */
|
||||
img[width='16'],
|
||||
img[width='16px'] {
|
||||
width: 16px !important;
|
||||
height: auto !important;
|
||||
}
|
||||
img[width='20'],
|
||||
img[width='20px'] {
|
||||
width: 20px !important;
|
||||
height: auto !important;
|
||||
}
|
||||
img[width='24'],
|
||||
img[width='24px'] {
|
||||
width: 24px !important;
|
||||
height: auto !important;
|
||||
}
|
||||
img[width='28'],
|
||||
img[width='28px'] {
|
||||
width: 28px !important;
|
||||
height: auto !important;
|
||||
}
|
||||
img[width='32'],
|
||||
img[width='32px'] {
|
||||
width: 32px !important;
|
||||
height: auto !important;
|
||||
}
|
||||
img[width='36'],
|
||||
img[width='36px'] {
|
||||
width: 36px !important;
|
||||
height: auto !important;
|
||||
}
|
||||
img[width='40'],
|
||||
img[width='40px'] {
|
||||
width: 40px !important;
|
||||
height: auto !important;
|
||||
}
|
||||
img[width='48'],
|
||||
img[width='48px'] {
|
||||
width: 48px !important;
|
||||
height: auto !important;
|
||||
}
|
||||
img[width='50'],
|
||||
img[width='50px'] {
|
||||
width: 50px !important;
|
||||
height: auto !important;
|
||||
}
|
||||
img[width='64'],
|
||||
img[width='64px'] {
|
||||
width: 64px !important;
|
||||
height: auto !important;
|
||||
}
|
||||
img[width='80'],
|
||||
img[width='80px'] {
|
||||
width: 80px !important;
|
||||
height: auto !important;
|
||||
}
|
||||
img[width='96'],
|
||||
img[width='96px'] {
|
||||
width: 96px !important;
|
||||
height: auto !important;
|
||||
}
|
||||
img[width='100'],
|
||||
img[width='100px'] {
|
||||
width: 100px !important;
|
||||
height: auto !important;
|
||||
}
|
||||
|
||||
/* Force No-Wrap for Recruiting Table cells, links, and headers across all devices */
|
||||
[class*='sis-recruiting-table'] th,
|
||||
[class*='sis-recruiting-table'] td,
|
||||
.sis-recruiting-title-link,
|
||||
.sis-recruiting-detail-btn {
|
||||
white-space: nowrap !important;
|
||||
}
|
||||
|
||||
@@ -91,7 +91,7 @@ class SISRawHtmlTool {
|
||||
this.readOnly = readOnly;
|
||||
this.data = {
|
||||
html: (data && data.html) ? data.html : '',
|
||||
stretched: !!(data && data.stretched)
|
||||
fullWidth: !!(data && (data.fullWidth || data.stretched))
|
||||
};
|
||||
this.textarea = null;
|
||||
this.editorInstance = null;
|
||||
@@ -104,22 +104,22 @@ class SISRawHtmlTool {
|
||||
|
||||
const stretchWrapper = document.createElement('div');
|
||||
stretchWrapper.className = 'custom-control custom-switch mb-2';
|
||||
this.stretchCheck = document.createElement('input');
|
||||
this.stretchCheck.type = 'checkbox';
|
||||
this.stretchCheck.className = 'custom-control-input';
|
||||
this.stretchCheck.id = 'raw_stretch_' + Math.random().toString(36).substring(7);
|
||||
this.stretchCheck.checked = !!(this.data && this.data.stretched);
|
||||
this.fullWidthCheck = document.createElement('input');
|
||||
this.fullWidthCheck.type = 'checkbox';
|
||||
this.fullWidthCheck.className = 'custom-control-input';
|
||||
this.fullWidthCheck.id = 'raw_stretch_' + Math.random().toString(36).substring(7);
|
||||
this.fullWidthCheck.checked = !!(this.data && this.data.fullWidth);
|
||||
|
||||
const stretchLabel = document.createElement('label');
|
||||
stretchLabel.className = 'custom-control-label small font-weight-bold text-secondary';
|
||||
stretchLabel.htmlFor = this.stretchCheck.id;
|
||||
stretchLabel.htmlFor = this.fullWidthCheck.id;
|
||||
stretchLabel.innerHTML = '<i class="fas fa-arrows-alt-h"></i> Stretch block to Full Screen Width (Independent Breakout)';
|
||||
|
||||
this.stretchCheck.addEventListener('change', () => {
|
||||
this.data.stretched = this.stretchCheck.checked;
|
||||
this.fullWidthCheck.addEventListener('change', () => {
|
||||
this.data.fullWidth = this.fullWidthCheck.checked;
|
||||
});
|
||||
|
||||
stretchWrapper.appendChild(this.stretchCheck);
|
||||
stretchWrapper.appendChild(this.fullWidthCheck);
|
||||
stretchWrapper.appendChild(stretchLabel);
|
||||
container.appendChild(stretchWrapper);
|
||||
|
||||
@@ -143,7 +143,7 @@ class SISRawHtmlTool {
|
||||
container.appendChild(this.textarea);
|
||||
|
||||
if (this.readOnly) {
|
||||
this.stretchCheck.disabled = true;
|
||||
this.fullWidthCheck.disabled = true;
|
||||
this.textarea.disabled = true;
|
||||
}
|
||||
|
||||
@@ -188,7 +188,7 @@ class SISRawHtmlTool {
|
||||
const htmlVal = this.editorInstance ? this.editorInstance.getValue() : (this.textarea ? this.textarea.value : (this.data.html || ''));
|
||||
return {
|
||||
html: htmlVal,
|
||||
stretched: this.stretchCheck ? this.stretchCheck.checked : !!(this.data && this.data.stretched)
|
||||
fullWidth: this.fullWidthCheck ? this.fullWidthCheck.checked : !!(this.data && this.data.fullWidth)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -215,7 +215,7 @@ class SISAccordionTool {
|
||||
title: (data && data.title) ? data.title : '',
|
||||
content: (data && data.content) ? data.content : '',
|
||||
isOpen: !!(data && data.isOpen),
|
||||
stretched: !!(data && data.stretched)
|
||||
fullWidth: !!(data && (data.fullWidth || data.stretched))
|
||||
};
|
||||
this.titleInput = null;
|
||||
this.contentInput = null;
|
||||
@@ -292,22 +292,22 @@ class SISAccordionTool {
|
||||
// Stretch toggle
|
||||
const stretchWrapper = document.createElement('div');
|
||||
stretchWrapper.className = 'custom-control custom-switch mt-1';
|
||||
this.stretchCheck = document.createElement('input');
|
||||
this.stretchCheck.type = 'checkbox';
|
||||
this.stretchCheck.className = 'custom-control-input';
|
||||
this.stretchCheck.id = 'acc_stretch_' + Math.random().toString(36).substring(7);
|
||||
this.stretchCheck.checked = !!(this.data && this.data.stretched);
|
||||
this.fullWidthCheck = document.createElement('input');
|
||||
this.fullWidthCheck.type = 'checkbox';
|
||||
this.fullWidthCheck.className = 'custom-control-input';
|
||||
this.fullWidthCheck.id = 'acc_stretch_' + Math.random().toString(36).substring(7);
|
||||
this.fullWidthCheck.checked = !!(this.data && this.data.fullWidth);
|
||||
|
||||
const stretchLabel = document.createElement('label');
|
||||
stretchLabel.className = 'custom-control-label small font-weight-bold text-secondary';
|
||||
stretchLabel.htmlFor = this.stretchCheck.id;
|
||||
stretchLabel.htmlFor = this.fullWidthCheck.id;
|
||||
stretchLabel.innerHTML = '<i class="fas fa-arrows-alt-h"></i> Stretch block to Full Screen Width (Independent Breakout)';
|
||||
|
||||
this.stretchCheck.addEventListener('change', () => {
|
||||
this.data.stretched = this.stretchCheck.checked;
|
||||
this.fullWidthCheck.addEventListener('change', () => {
|
||||
this.data.fullWidth = this.fullWidthCheck.checked;
|
||||
});
|
||||
|
||||
stretchWrapper.appendChild(this.stretchCheck);
|
||||
stretchWrapper.appendChild(this.fullWidthCheck);
|
||||
stretchWrapper.appendChild(stretchLabel);
|
||||
container.appendChild(stretchWrapper);
|
||||
|
||||
@@ -319,218 +319,12 @@ class SISAccordionTool {
|
||||
title: this.titleInput ? this.titleInput.value : this.data.title,
|
||||
content: this.contentInput ? this.contentInput.value : this.data.content,
|
||||
isOpen: this.openCheck ? this.openCheck.checked : !!(this.data && this.data.isOpen),
|
||||
stretched: this.stretchCheck ? this.stretchCheck.checked : !!(this.data && this.data.stretched)
|
||||
fullWidth: this.fullWidthCheck ? this.fullWidthCheck.checked : !!(this.data && this.data.fullWidth)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class SISYouTubeTool {
|
||||
static get toolbox() {
|
||||
return {
|
||||
title: 'YouTube Video',
|
||||
icon: '<svg width="20" height="20" viewBox="0 0 24 24" fill="red"><path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z"/></svg>'
|
||||
};
|
||||
}
|
||||
|
||||
constructor({ data, api, readOnly }) {
|
||||
this.api = api;
|
||||
this.readOnly = readOnly;
|
||||
this.data = {
|
||||
url: (data && data.url) ? data.url : '',
|
||||
thumbnailUrl: (data && data.thumbnailUrl) ? data.thumbnailUrl : '',
|
||||
caption: (data && data.caption) ? data.caption : '',
|
||||
stretched: !!(data && data.stretched)
|
||||
};
|
||||
this.urlInput = null;
|
||||
this.thumbnailInput = null;
|
||||
this.captionInput = null;
|
||||
this.previewContainer = null;
|
||||
this.iframe = null;
|
||||
this.isIframeActive = false;
|
||||
}
|
||||
|
||||
extractVideoId(input) {
|
||||
if (!input) return '';
|
||||
var regExp = /^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|\&v=)([^#\&\?]*).*/;
|
||||
var match = input.match(regExp);
|
||||
if (match && match[2].length === 11) {
|
||||
return match[2];
|
||||
}
|
||||
if (input.length === 11) {
|
||||
return input;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
getEmbedUrl(videoId) {
|
||||
return videoId ? 'https://www.youtube.com/embed/' + videoId : '';
|
||||
}
|
||||
|
||||
getThumbnailUrl(videoId) {
|
||||
return videoId ? 'https://img.youtube.com/vi/' + videoId + '/hqdefault.jpg' : '';
|
||||
}
|
||||
|
||||
render() {
|
||||
const container = document.createElement('div');
|
||||
container.style.border = '1px solid #e3e6f0';
|
||||
container.style.borderRadius = '8px';
|
||||
container.style.padding = '14px';
|
||||
container.style.background = '#fff';
|
||||
container.style.marginBottom = '12px';
|
||||
|
||||
const label = document.createElement('label');
|
||||
label.className = 'font-weight-bold text-danger small mb-1';
|
||||
label.innerHTML = '<i class="fab fa-youtube"></i> YouTube Video Link / URL / ID';
|
||||
|
||||
this.urlInput = document.createElement('input');
|
||||
this.urlInput.type = 'text';
|
||||
this.urlInput.className = 'form-control mb-2';
|
||||
this.urlInput.placeholder = 'Paste YouTube URL (e.g. https://www.youtube.com/watch?v=sDE4ZZdNOOY)...';
|
||||
this.urlInput.value = this.data.url;
|
||||
|
||||
// Custom Thumbnail Field
|
||||
const thumbLabel = document.createElement('label');
|
||||
thumbLabel.className = 'font-weight-bold text-secondary small mb-1';
|
||||
thumbLabel.innerHTML = '<i class="fas fa-image"></i> Custom Thumbnail Image URL (Optional - Overrides YouTube Cover)';
|
||||
|
||||
this.thumbnailInput = document.createElement('input');
|
||||
this.thumbnailInput.type = 'text';
|
||||
this.thumbnailInput.className = 'form-control form-control-sm mb-2';
|
||||
this.thumbnailInput.placeholder = 'Custom Thumbnail Image URL (leave blank to auto-use YouTube cover)...';
|
||||
this.thumbnailInput.value = this.data.thumbnailUrl;
|
||||
|
||||
// Thumbnail Preview Container
|
||||
this.previewContainer = document.createElement('div');
|
||||
this.previewContainer.className = 'yt-thumbnail-preview';
|
||||
this.previewContainer.style.position = 'relative';
|
||||
this.previewContainer.style.paddingBottom = '56.25%';
|
||||
this.previewContainer.style.height = '0';
|
||||
this.previewContainer.style.overflow = 'hidden';
|
||||
this.previewContainer.style.background = '#111 center/cover no-repeat';
|
||||
this.previewContainer.style.borderRadius = '6px';
|
||||
this.previewContainer.style.marginBottom = '8px';
|
||||
this.previewContainer.style.cursor = 'pointer';
|
||||
this.previewContainer.style.boxShadow = '0 2px 8px rgba(0,0,0,0.15)';
|
||||
|
||||
// Play Button Overlay
|
||||
const playBtn = document.createElement('div');
|
||||
playBtn.className = 'yt-play-button-overlay';
|
||||
playBtn.style.position = 'absolute';
|
||||
playBtn.style.top = '50%';
|
||||
playBtn.style.left = '50%';
|
||||
playBtn.style.transform = 'translate(-50%, -50%)';
|
||||
playBtn.style.transition = 'transform 0.2s ease';
|
||||
playBtn.innerHTML = '<svg width="68" height="48" viewBox="0 0 68 48"><path d="M66.52 7.74c-.78-2.93-2.49-5.41-5.42-6.19C55.79.13 34 0 34 0S12.21.13 6.9 1.55c-2.93.78-4.63 3.26-5.42 6.19C.06 13.05 0 24 0 24s.06 10.95 1.48 16.26c.78 2.93 2.49 5.41 5.42 6.19C12.21 47.87 34 48 34 48s21.79-.13 27.1-1.55c2.93-.78 4.64-3.26 5.42-6.19C67.94 34.95 68 24 68 24s-.06-10.95-1.48-16.26z" fill="#ff0000"/><path d="M45 24L27 14v20z" fill="#ffffff"/></svg>';
|
||||
|
||||
this.previewContainer.appendChild(playBtn);
|
||||
|
||||
// Update Thumbnail Image
|
||||
const updatePreview = () => {
|
||||
const customThumb = this.thumbnailInput.value.trim();
|
||||
const videoId = this.extractVideoId(this.urlInput.value);
|
||||
const thumbUrl = customThumb || this.getThumbnailUrl(videoId);
|
||||
if (thumbUrl) {
|
||||
this.previewContainer.style.backgroundImage = 'url("' + thumbUrl + '")';
|
||||
playBtn.style.display = 'block';
|
||||
} else {
|
||||
this.previewContainer.style.backgroundImage = 'none';
|
||||
this.previewContainer.style.backgroundColor = '#222';
|
||||
}
|
||||
};
|
||||
|
||||
// Click thumbnail to play live video
|
||||
this.previewContainer.addEventListener('click', () => {
|
||||
const videoId = this.extractVideoId(this.urlInput.value);
|
||||
if (videoId && !this.isIframeActive) {
|
||||
this.isIframeActive = true;
|
||||
this.previewContainer.innerHTML = '';
|
||||
const iframe = document.createElement('iframe');
|
||||
iframe.style.position = 'absolute';
|
||||
iframe.style.top = '0';
|
||||
iframe.style.left = '0';
|
||||
iframe.style.width = '100%';
|
||||
iframe.style.height = '100%';
|
||||
iframe.style.border = '0';
|
||||
iframe.setAttribute('allow', 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share');
|
||||
iframe.setAttribute('allowfullscreen', 'true');
|
||||
iframe.src = this.getEmbedUrl(videoId) + '?autoplay=1';
|
||||
this.previewContainer.appendChild(iframe);
|
||||
}
|
||||
});
|
||||
|
||||
updatePreview();
|
||||
|
||||
this.captionInput = document.createElement('input');
|
||||
this.captionInput.type = 'text';
|
||||
this.captionInput.className = 'form-control form-control-sm mb-2';
|
||||
this.captionInput.placeholder = 'Video caption (optional)...';
|
||||
this.captionInput.value = this.data.caption;
|
||||
|
||||
if (this.readOnly) {
|
||||
this.urlInput.disabled = true;
|
||||
this.thumbnailInput.disabled = true;
|
||||
this.captionInput.disabled = true;
|
||||
}
|
||||
|
||||
this.urlInput.addEventListener('input', () => {
|
||||
const videoId = this.extractVideoId(this.urlInput.value);
|
||||
this.data.url = this.getEmbedUrl(videoId);
|
||||
this.isIframeActive = false;
|
||||
this.previewContainer.innerHTML = '';
|
||||
this.previewContainer.appendChild(playBtn);
|
||||
updatePreview();
|
||||
});
|
||||
|
||||
this.thumbnailInput.addEventListener('input', () => {
|
||||
this.data.thumbnailUrl = this.thumbnailInput.value.trim();
|
||||
updatePreview();
|
||||
});
|
||||
|
||||
this.captionInput.addEventListener('input', () => {
|
||||
this.data.caption = this.captionInput.value;
|
||||
});
|
||||
|
||||
container.appendChild(label);
|
||||
container.appendChild(this.urlInput);
|
||||
container.appendChild(thumbLabel);
|
||||
container.appendChild(this.thumbnailInput);
|
||||
container.appendChild(this.previewContainer);
|
||||
container.appendChild(this.captionInput);
|
||||
|
||||
const stretchWrapper = document.createElement('div');
|
||||
stretchWrapper.className = 'custom-control custom-switch mt-2';
|
||||
this.stretchCheck = document.createElement('input');
|
||||
this.stretchCheck.type = 'checkbox';
|
||||
this.stretchCheck.className = 'custom-control-input';
|
||||
this.stretchCheck.id = 'yt_stretch_' + Math.random().toString(36).substring(7);
|
||||
this.stretchCheck.checked = !!(this.data && this.data.stretched);
|
||||
|
||||
const stretchLabel = document.createElement('label');
|
||||
stretchLabel.className = 'custom-control-label small font-weight-bold text-secondary';
|
||||
stretchLabel.htmlFor = this.stretchCheck.id;
|
||||
stretchLabel.innerHTML = '<i class="fas fa-arrows-alt-h"></i> Stretch block to Full Screen Width (Independent Breakout)';
|
||||
|
||||
this.stretchCheck.addEventListener('change', () => {
|
||||
this.data.stretched = this.stretchCheck.checked;
|
||||
});
|
||||
|
||||
stretchWrapper.appendChild(this.stretchCheck);
|
||||
stretchWrapper.appendChild(stretchLabel);
|
||||
container.appendChild(stretchWrapper);
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
save() {
|
||||
const videoId = this.extractVideoId(this.urlInput ? this.urlInput.value : this.data.url);
|
||||
return {
|
||||
url: this.getEmbedUrl(videoId) || this.data.url,
|
||||
thumbnailUrl: this.thumbnailInput ? this.thumbnailInput.value.trim() : (this.data.thumbnailUrl || ''),
|
||||
caption: this.captionInput ? this.captionInput.value : this.data.caption,
|
||||
stretched: this.stretchCheck ? this.stretchCheck.checked : !!(this.data && this.data.stretched)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// SISHeroBannerTool extracted to /js/manage/editor-plugins/hero-banner.js
|
||||
|
||||
@@ -613,125 +407,18 @@ function initSISEditor(holderId, hiddenInputId, initialData, skipSubmitHandler =
|
||||
if (typeof Marker !== 'undefined') builtInTools.marker = { class: Marker };
|
||||
if (typeof InlineCode !== 'undefined') builtInTools.inlineCode = { class: InlineCode };
|
||||
if (typeof Underline !== 'undefined') builtInTools.underline = { class: Underline };
|
||||
// Custom Image Block that bypasses default ImageTool uploader and triggers SISMediaPicker directly
|
||||
class SISCustomImageTool {
|
||||
static get toolbox() {
|
||||
return {
|
||||
title: 'Image',
|
||||
icon: '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect><circle cx="8.5" cy="8.5" r="1.5"></circle><polyline points="21 15 16 10 5 21"></polyline></svg>'
|
||||
};
|
||||
}
|
||||
|
||||
constructor({ data, api, readOnly }) {
|
||||
this.api = api;
|
||||
this.readOnly = readOnly;
|
||||
this.data = {
|
||||
file: data.file || { url: '' },
|
||||
caption: data.caption || '',
|
||||
withBorder: !!data.withBorder,
|
||||
stretched: !!data.stretched,
|
||||
withBackground: !!data.withBackground
|
||||
};
|
||||
this.container = null;
|
||||
}
|
||||
|
||||
render() {
|
||||
this.container = document.createElement('div');
|
||||
this.container.className = 'sis-custom-image-block-wrapper position-relative py-2';
|
||||
|
||||
if (!this.data.file.url) {
|
||||
// Render placeholder / picker launcher
|
||||
const trigger = document.createElement('div');
|
||||
trigger.className = 'border rounded p-4 text-center cursor-pointer text-muted bg-light d-flex flex-column align-items-center justify-content-center';
|
||||
trigger.style.minHeight = '150px';
|
||||
trigger.innerHTML = '<i class="fas fa-images fa-2x mb-2 text-primary"></i><span>Chọn ảnh từ Thư viện (Media Library)</span>';
|
||||
trigger.addEventListener('click', () => this._openPicker());
|
||||
this.container.appendChild(trigger);
|
||||
} else {
|
||||
// Render Image preview along with quick settings button
|
||||
this._renderPreview();
|
||||
}
|
||||
|
||||
return this.container;
|
||||
}
|
||||
|
||||
_openPicker() {
|
||||
if (window.SISMediaPicker) {
|
||||
SISMediaPicker.open((url, mediaObj, config) => {
|
||||
if (url) {
|
||||
this.data.file = {
|
||||
url: url,
|
||||
style: config ? config.style : '',
|
||||
cssClass: config ? config.cssClass : '',
|
||||
alt: config ? config.alt : '',
|
||||
customAttributes: config ? config.customAttributes : '',
|
||||
aspectRatio: config ? config.aspectRatio : ''
|
||||
};
|
||||
this.data.caption = (config && config.alt) || '';
|
||||
this._renderPreview();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
alert('SISMediaPicker is not loaded.');
|
||||
}
|
||||
}
|
||||
|
||||
_renderPreview() {
|
||||
if (!this.container) return;
|
||||
this.container.innerHTML = '';
|
||||
|
||||
const imgWrapper = document.createElement('div');
|
||||
imgWrapper.className = 'position-relative text-center d-inline-block w-100';
|
||||
|
||||
const img = document.createElement('img');
|
||||
img.src = this.data.file.url;
|
||||
img.className = 'img-fluid rounded shadow-sm';
|
||||
if (this.data.file.style) img.style.cssText = this.data.file.style;
|
||||
if (this.data.file.cssClass) img.className = this.data.file.cssClass;
|
||||
if (this.data.file.alt) img.alt = this.data.file.alt;
|
||||
|
||||
// Re-configure button
|
||||
if (!this.readOnly) {
|
||||
const settingsBtn = document.createElement('button');
|
||||
settingsBtn.type = 'button';
|
||||
settingsBtn.className = 'btn btn-xs btn-primary shadow-sm position-absolute';
|
||||
settingsBtn.style.cssText = 'top: 10px; right: 10px; z-index: 100; font-size: 11px; padding: 2px 8px; border-radius: 4px; opacity: 0.85;';
|
||||
settingsBtn.innerHTML = '<i class="fas fa-cog mr-1"></i> Quick settings';
|
||||
settingsBtn.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this._openPicker();
|
||||
});
|
||||
imgWrapper.appendChild(settingsBtn);
|
||||
}
|
||||
|
||||
imgWrapper.appendChild(img);
|
||||
this.container.appendChild(imgWrapper);
|
||||
|
||||
// Caption input underneath image
|
||||
const captionInput = document.createElement('input');
|
||||
captionInput.type = 'text';
|
||||
captionInput.className = 'form-control form-control-sm text-center border-0 text-muted mt-2';
|
||||
captionInput.placeholder = 'Nhập chú thích hình ảnh (Caption)...';
|
||||
captionInput.value = this.data.caption || '';
|
||||
captionInput.disabled = this.readOnly;
|
||||
captionInput.addEventListener('input', (e) => {
|
||||
this.data.caption = e.target.value;
|
||||
});
|
||||
this.container.appendChild(captionInput);
|
||||
}
|
||||
|
||||
save(block) {
|
||||
return this.data;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof ImageTool !== 'undefined' || true) {
|
||||
if (typeof SISCustomImageTool !== 'undefined' || window.SISCustomImageTool) {
|
||||
const imageClass = typeof SISCustomImageTool !== 'undefined' ? SISCustomImageTool : window.SISCustomImageTool;
|
||||
builtInTools.image = {
|
||||
class: SISCustomImageTool
|
||||
class: imageClass
|
||||
};
|
||||
window.SISEditorPlugins.image = builtInTools.image;
|
||||
}
|
||||
|
||||
if (typeof SISTabPluginTool !== 'undefined' || window.SISTabPluginTool) {
|
||||
const toolClass = typeof SISTabPluginTool !== 'undefined' ? SISTabPluginTool : window.SISTabPluginTool;
|
||||
builtInTools.tabPlugin = { class: toolClass };
|
||||
}
|
||||
if (typeof AttachesTool !== 'undefined') {
|
||||
builtInTools.attaches = {
|
||||
class: AttachesTool,
|
||||
@@ -754,7 +441,23 @@ function initSISEditor(holderId, hiddenInputId, initialData, skipSubmitHandler =
|
||||
}
|
||||
|
||||
// === Merge built-in tools with any registered plugins ===
|
||||
var allTools = Object.assign({}, builtInTools, window.SISEditorPlugins);
|
||||
var sourceTools = Object.assign({}, builtInTools, window.SISEditorPlugins);
|
||||
var allTools = {};
|
||||
var registeredClasses = new Set();
|
||||
|
||||
for (var key in sourceTools) {
|
||||
if (Object.prototype.hasOwnProperty.call(sourceTools, key)) {
|
||||
var toolDef = sourceTools[key];
|
||||
var cls = toolDef ? toolDef.class : null;
|
||||
if (cls && registeredClasses.has(cls)) {
|
||||
continue;
|
||||
}
|
||||
if (cls) {
|
||||
registeredClasses.add(cls);
|
||||
}
|
||||
allTools[key] = toolDef;
|
||||
}
|
||||
}
|
||||
|
||||
// Apply the textStyling tune to all block tools dynamically
|
||||
for (var toolName in allTools) {
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* CMS Plugin Shortcode Tool for Editor.js (SIS Vietnam)
|
||||
* Enables inserting plugin shortcodes like [plugin:price-table] or [plugin:price-table id="table_1"]
|
||||
*/
|
||||
class CMSPluginTool {
|
||||
static get toolbox() {
|
||||
return {
|
||||
title: 'CMS Plugin',
|
||||
icon: '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path><polyline points="3.27 6.96 12 12.01 20.73 6.96"></polyline><line x1="12" y1="22.08" x2="12" y2="12"></line></svg>'
|
||||
};
|
||||
}
|
||||
|
||||
constructor({ data, api, readOnly }) {
|
||||
this.api = api;
|
||||
this.readOnly = readOnly;
|
||||
const initialVal = (data && (data.id || data.shortcode)) ? (data.id || data.shortcode) : '[plugin:price-table]';
|
||||
this.data = {
|
||||
id: initialVal,
|
||||
shortcode: initialVal
|
||||
};
|
||||
this.wrapper = null;
|
||||
}
|
||||
|
||||
render() {
|
||||
this.wrapper = document.createElement('div');
|
||||
this.wrapper.style.border = '1px solid #4e73df';
|
||||
this.wrapper.style.borderRadius = '6px';
|
||||
this.wrapper.style.background = '#f8fafc';
|
||||
this.wrapper.style.padding = '15px';
|
||||
this.wrapper.style.marginBottom = '12px';
|
||||
|
||||
const header = document.createElement('div');
|
||||
header.style.display = 'flex';
|
||||
header.style.justifyContent = 'space-between';
|
||||
header.style.alignItems = 'center';
|
||||
header.style.marginBottom = '10px';
|
||||
|
||||
const title = document.createElement('label');
|
||||
title.className = 'font-weight-bold text-primary m-0';
|
||||
title.innerHTML = '🧩 CMS Plugin Shortcode Injection';
|
||||
title.style.fontSize = '14px';
|
||||
|
||||
header.appendChild(title);
|
||||
this.wrapper.appendChild(header);
|
||||
|
||||
// Preset Quick Select Dropdown
|
||||
const selectContainer = document.createElement('div');
|
||||
selectContainer.style.marginBottom = '10px';
|
||||
|
||||
const selectLabel = document.createElement('label');
|
||||
selectLabel.className = 'small text-muted font-weight-bold';
|
||||
selectLabel.innerText = 'Chọn Plugin có sẵn:';
|
||||
|
||||
const select = document.createElement('select');
|
||||
select.className = 'form-control form-control-sm';
|
||||
select.style.marginBottom = '8px';
|
||||
|
||||
select.innerHTML = `
|
||||
<option value="">-- Chọn Shortcode Plugin --</option>
|
||||
<option value="[plugin:recruiting-table]">Bảng Tuyển dụng Mặc định [plugin:recruiting-table]</option>
|
||||
<option value='[plugin:recruiting-table id="recruiting_1"]'>Bảng Tuyển dụng theo ID [plugin:recruiting-table id="recruiting_1"]</option>
|
||||
<option value='[plugin:recruiting-table id="recruiting_2"]'>Bảng Tuyển dụng theo ID [plugin:recruiting-table id="recruiting_2"]</option>
|
||||
<option value="[plugin:price-table]">Bảng giá Mặc định [plugin:price-table]</option>
|
||||
<option value='[plugin:price-table id="table_1"]'>Bảng giá theo ID [plugin:price-table id="table_1"]</option>
|
||||
<option value="custom">-- Nhập Shortcode Tùy chỉnh --</option>
|
||||
`;
|
||||
|
||||
selectContainer.appendChild(selectLabel);
|
||||
selectContainer.appendChild(select);
|
||||
this.wrapper.appendChild(selectContainer);
|
||||
|
||||
// Input Field
|
||||
const inputLabel = document.createElement('label');
|
||||
inputLabel.className = 'small text-muted font-weight-bold';
|
||||
inputLabel.innerText = 'Mã Shortcode Plugin:';
|
||||
|
||||
const input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
input.className = 'form-control font-weight-bold';
|
||||
input.placeholder = 'e.g. [plugin:price-table id="table_1"]';
|
||||
input.value = this.data.shortcode || this.data.id || '';
|
||||
if (this.readOnly) input.disabled = true;
|
||||
|
||||
select.addEventListener('change', (e) => {
|
||||
const val = e.target.value;
|
||||
if (val && val !== 'custom') {
|
||||
input.value = val;
|
||||
this.data.shortcode = val;
|
||||
this.data.id = val;
|
||||
}
|
||||
});
|
||||
|
||||
input.addEventListener('input', (e) => {
|
||||
this.data.shortcode = e.target.value.trim();
|
||||
this.data.id = e.target.value.trim();
|
||||
});
|
||||
|
||||
this.wrapper.appendChild(inputLabel);
|
||||
this.wrapper.appendChild(input);
|
||||
|
||||
return this.wrapper;
|
||||
}
|
||||
|
||||
save() {
|
||||
return {
|
||||
id: this.data.id || this.data.shortcode || '',
|
||||
shortcode: this.data.shortcode || this.data.id || ''
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Register the plugin globally
|
||||
window.SISEditorPlugins = window.SISEditorPlugins || {};
|
||||
window.SISEditorPlugins['cmsPlugin'] = {
|
||||
class: CMSPluginTool
|
||||
};
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Custom Image Tool for Editor.js (SIS Vietnam)
|
||||
* Directly integrates with SISMediaPicker for easy image selection and quick configuration.
|
||||
*/
|
||||
class SISCustomImageTool {
|
||||
static get toolbox() {
|
||||
return {
|
||||
title: 'Image',
|
||||
icon: '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect><circle cx="8.5" cy="8.5" r="1.5"></circle><polyline points="21 15 16 10 5 21"></polyline></svg>'
|
||||
};
|
||||
}
|
||||
|
||||
constructor({ data, api, readOnly }) {
|
||||
this.api = api;
|
||||
this.readOnly = readOnly;
|
||||
this.data = {
|
||||
file: data.file || { url: '' },
|
||||
caption: data.caption || '',
|
||||
withBorder: !!data.withBorder,
|
||||
stretched: !!data.stretched,
|
||||
withBackground: !!data.withBackground
|
||||
};
|
||||
this.container = null;
|
||||
}
|
||||
|
||||
render() {
|
||||
this.container = document.createElement('div');
|
||||
this.container.className = 'sis-custom-image-block-wrapper position-relative py-2';
|
||||
|
||||
if (!this.data.file.url) {
|
||||
// Render placeholder / picker launcher
|
||||
const trigger = document.createElement('div');
|
||||
trigger.className = 'border rounded p-4 text-center cursor-pointer text-muted bg-light d-flex flex-column align-items-center justify-content-center';
|
||||
trigger.style.minHeight = '150px';
|
||||
trigger.innerHTML = '<i class="fas fa-images fa-2x mb-2 text-primary"></i><span>Chọn ảnh từ Thư viện (Media Library)</span>';
|
||||
trigger.addEventListener('click', () => this._openPicker());
|
||||
this.container.appendChild(trigger);
|
||||
} else {
|
||||
// Render Image preview along with quick settings button
|
||||
this._renderPreview();
|
||||
}
|
||||
|
||||
return this.container;
|
||||
}
|
||||
|
||||
_openPicker() {
|
||||
if (window.SISMediaPicker) {
|
||||
SISMediaPicker.open((url, mediaObj, config) => {
|
||||
if (url) {
|
||||
const imgId = config ? (config.id || config.elementId || '') : '';
|
||||
this.data.file = {
|
||||
url: url,
|
||||
id: imgId,
|
||||
elementId: imgId,
|
||||
style: config ? config.style : '',
|
||||
cssClass: config ? config.cssClass : '',
|
||||
alt: config ? config.alt : '',
|
||||
customAttributes: config ? config.customAttributes : '',
|
||||
aspectRatio: config ? config.aspectRatio : ''
|
||||
};
|
||||
this.data.caption = (config && config.alt) || '';
|
||||
this._renderPreview();
|
||||
}
|
||||
}, this.data.file ? this.data.file.url : '', this.data.file || {});
|
||||
} else {
|
||||
alert('SISMediaPicker is not loaded.');
|
||||
}
|
||||
}
|
||||
|
||||
_renderPreview() {
|
||||
if (!this.container) return;
|
||||
this.container.innerHTML = '';
|
||||
|
||||
const imgWrapper = document.createElement('div');
|
||||
imgWrapper.className = 'position-relative text-center d-inline-block w-100';
|
||||
|
||||
const img = document.createElement('img');
|
||||
img.src = this.data.file.url;
|
||||
img.className = 'img-fluid rounded shadow-sm';
|
||||
if (this.data.file.id || this.data.file.elementId) img.id = this.data.file.id || this.data.file.elementId;
|
||||
if (this.data.file.style) img.style.cssText = this.data.file.style;
|
||||
if (this.data.file.cssClass) img.className = this.data.file.cssClass;
|
||||
if (this.data.file.alt) img.alt = this.data.file.alt;
|
||||
|
||||
// Re-configure button
|
||||
if (!this.readOnly) {
|
||||
const settingsBtn = document.createElement('button');
|
||||
settingsBtn.type = 'button';
|
||||
settingsBtn.className = 'btn btn-xs btn-primary shadow-sm position-absolute';
|
||||
settingsBtn.style.cssText = 'top: 10px; right: 10px; z-index: 100; font-size: 11px; padding: 2px 8px; border-radius: 4px; opacity: 0.85;';
|
||||
settingsBtn.innerHTML = '<i class="fas fa-cog mr-1"></i> Quick settings';
|
||||
settingsBtn.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this._openPicker();
|
||||
});
|
||||
imgWrapper.appendChild(settingsBtn);
|
||||
}
|
||||
|
||||
imgWrapper.appendChild(img);
|
||||
this.container.appendChild(imgWrapper);
|
||||
|
||||
// Caption input underneath image
|
||||
const captionInput = document.createElement('input');
|
||||
captionInput.type = 'text';
|
||||
captionInput.className = 'form-control form-control-sm text-center border-0 text-muted mt-2';
|
||||
captionInput.placeholder = 'Nhập chú thích hình ảnh (Caption)...';
|
||||
captionInput.value = this.data.caption || '';
|
||||
captionInput.disabled = this.readOnly;
|
||||
captionInput.addEventListener('input', (e) => {
|
||||
this.data.caption = e.target.value;
|
||||
});
|
||||
this.container.appendChild(captionInput);
|
||||
}
|
||||
|
||||
save(block) {
|
||||
return this.data;
|
||||
}
|
||||
}
|
||||
|
||||
// Register globally
|
||||
window.SISCustomImageTool = SISCustomImageTool;
|
||||
window.SISEditorPlugins = window.SISEditorPlugins || {};
|
||||
window.SISEditorPlugins['image'] = {
|
||||
class: SISCustomImageTool
|
||||
};
|
||||
@@ -161,15 +161,21 @@ class FlexTool {
|
||||
|
||||
// 1. Flex Items (Cols)
|
||||
const colDiv = document.createElement('div');
|
||||
colDiv.className = 'col-md-2 mb-2';
|
||||
colDiv.className = 'col-md-3 mb-2';
|
||||
colDiv.innerHTML = '<label class="small font-weight-bold text-secondary mb-1">Flex Items</label>';
|
||||
|
||||
const colControlGroup = document.createElement('div');
|
||||
colControlGroup.className = 'd-flex align-items-center';
|
||||
|
||||
const input = document.createElement('input');
|
||||
input.type = 'number';
|
||||
input.className = 'form-control form-control-sm';
|
||||
input.className = 'form-control form-control-sm mr-1';
|
||||
input.style.width = '65px';
|
||||
input.min = '1';
|
||||
input.max = '12';
|
||||
input.value = this.data.cols;
|
||||
if (this.readOnly) input.disabled = true;
|
||||
this.colsInput = input;
|
||||
input.addEventListener('input', (e) => {
|
||||
let val = parseInt(e.target.value) || 2;
|
||||
if (val < 1) val = 1;
|
||||
@@ -177,7 +183,18 @@ class FlexTool {
|
||||
this.data.cols = val;
|
||||
this._renderColumnInputs();
|
||||
});
|
||||
colDiv.appendChild(input);
|
||||
colControlGroup.appendChild(input);
|
||||
|
||||
const addColBtn = document.createElement('button');
|
||||
addColBtn.type = 'button';
|
||||
addColBtn.className = 'btn btn-sm btn-outline-primary font-weight-bold';
|
||||
addColBtn.title = 'Add new Flex Item';
|
||||
addColBtn.innerHTML = '<i class="fas fa-plus"></i> + Item';
|
||||
if (this.readOnly) addColBtn.disabled = true;
|
||||
addColBtn.addEventListener('click', () => this._addItem(this.data.cols));
|
||||
colControlGroup.appendChild(addColBtn);
|
||||
|
||||
colDiv.appendChild(colControlGroup);
|
||||
bgSettingsRow.appendChild(colDiv);
|
||||
|
||||
// 2. Global CSS Class
|
||||
@@ -212,12 +229,12 @@ class FlexTool {
|
||||
|
||||
// 4. Global Custom Attributes
|
||||
const attrDiv = document.createElement('div');
|
||||
attrDiv.className = 'col-md-4 mb-2';
|
||||
attrDiv.className = 'col-md-3 mb-2';
|
||||
attrDiv.innerHTML = '<label class="small font-weight-bold text-secondary mb-1"><i class="fas fa-sliders-h"></i> Custom Attributes</label>';
|
||||
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:#fff"';
|
||||
attrInput.placeholder = 'e.g. data-aos="fade-up"';
|
||||
attrInput.value = this.data.globalAttributes || '';
|
||||
if (this.readOnly) attrInput.disabled = true;
|
||||
attrInput.addEventListener('input', (e) => this.data.globalAttributes = e.target.value.trim());
|
||||
@@ -236,6 +253,277 @@ class FlexTool {
|
||||
return this.wrapper;
|
||||
}
|
||||
|
||||
_getItemData(i) {
|
||||
return {
|
||||
type: this.data[`type${i}`] || 'html',
|
||||
col: this.data[`col${i}`] || '',
|
||||
imgUrl: this.data[`imgUrl${i}`] || '',
|
||||
ytUrl: this.data[`ytUrl${i}`] || '',
|
||||
ytThumbUrl: this.data[`ytThumbUrl${i}`] || '',
|
||||
ytStretched: !!this.data[`ytStretched${i}`],
|
||||
accTitle: this.data[`accTitle${i}`] || '',
|
||||
accContent: this.data[`accContent${i}`] || '',
|
||||
class: this.data[`class${i}`] || '',
|
||||
id: this.data[`id${i}`] || '',
|
||||
attr: this.data[`attr${i}`] || '',
|
||||
width: this.data[`width${i}`] || 0,
|
||||
caption: this.data[`caption${i}`] || '',
|
||||
subType: this.data[`subType${i}`] || 'none',
|
||||
colsub_: this.data[`colsub_${i}`] || '',
|
||||
imgUrlsub_: this.data[`imgUrlsub_${i}`] || '',
|
||||
ytUrlsub_: this.data[`ytUrlsub_${i}`] || '',
|
||||
ytThumbUrlsub_: this.data[`ytThumbUrlsub_${i}`] || '',
|
||||
ytStretchedsub_: !!this.data[`ytStretchedsub_${i}`],
|
||||
accTitlesub_: this.data[`accTitlesub_${i}`] || '',
|
||||
accContentsub_: this.data[`accContentsub_${i}`] || '',
|
||||
classsub_: this.data[`classsub_${i}`] || '',
|
||||
idsub_: this.data[`idsub_${i}`] || '',
|
||||
attrsub_: this.data[`attrsub_${i}`] || ''
|
||||
};
|
||||
}
|
||||
|
||||
_setItemData(i, obj) {
|
||||
this.data[`type${i}`] = obj.type || 'html';
|
||||
let colVal = obj.col !== undefined && obj.col !== null ? obj.col : '';
|
||||
if (typeof colVal === 'object') {
|
||||
colVal = JSON.stringify(colVal);
|
||||
}
|
||||
this.data[`col${i}`] = colVal;
|
||||
this.data[`imgUrl${i}`] = obj.imgUrl || '';
|
||||
this.data[`ytUrl${i}`] = obj.ytUrl || '';
|
||||
this.data[`ytThumbUrl${i}`] = obj.ytThumbUrl || obj.imgUrl || '';
|
||||
this.data[`ytStretched${i}`] = !!obj.ytStretched;
|
||||
this.data[`accTitle${i}`] = obj.accTitle || '';
|
||||
this.data[`accContent${i}`] = obj.accContent || '';
|
||||
this.data[`class${i}`] = obj.class || '';
|
||||
this.data[`id${i}`] = obj.id || '';
|
||||
this.data[`attr${i}`] = obj.attr || '';
|
||||
this.data[`width${i}`] = obj.width || 0;
|
||||
this.data[`caption${i}`] = obj.caption || '';
|
||||
this.data[`subType${i}`] = obj.subType || 'none';
|
||||
let colsubVal = obj.colsub_ !== undefined && obj.colsub_ !== null ? obj.colsub_ : '';
|
||||
if (typeof colsubVal === 'object') {
|
||||
colsubVal = JSON.stringify(colsubVal);
|
||||
}
|
||||
this.data[`colsub_${i}`] = colsubVal;
|
||||
this.data[`imgUrlsub_${i}`] = obj.imgUrlsub_ || '';
|
||||
this.data[`ytUrlsub_${i}`] = obj.ytUrlsub_ || '';
|
||||
this.data[`ytThumbUrlsub_${i}`] = obj.ytThumbUrlsub_ || obj.imgUrlsub_ || '';
|
||||
this.data[`ytStretchedsub_${i}`] = !!obj.ytStretchedsub_;
|
||||
this.data[`accTitlesub_${i}`] = obj.accTitlesub_ || '';
|
||||
this.data[`accContentsub_${i}`] = obj.accContentsub_ || '';
|
||||
this.data[`classsub_${i}`] = obj.classsub_ || '';
|
||||
this.data[`idsub_${i}`] = obj.idsub_ || '';
|
||||
this.data[`attrsub_${i}`] = obj.attrsub_ || '';
|
||||
}
|
||||
|
||||
_swapItems(i, j) {
|
||||
const itemI = this._getItemData(i);
|
||||
const itemJ = this._getItemData(j);
|
||||
this._setItemData(i, itemJ);
|
||||
this._setItemData(j, itemI);
|
||||
this._renderColumnInputs();
|
||||
}
|
||||
|
||||
_addItem(afterIdx) {
|
||||
if (this.data.cols >= 12) return;
|
||||
const newCols = this.data.cols + 1;
|
||||
for (let i = newCols; i > afterIdx + 1; i--) {
|
||||
this._setItemData(i, this._getItemData(i - 1));
|
||||
}
|
||||
this._setItemData(afterIdx + 1, { type: 'html', col: '', subType: 'none' });
|
||||
this.data.cols = newCols;
|
||||
if (this.colsInput) this.colsInput.value = newCols;
|
||||
this._renderColumnInputs();
|
||||
}
|
||||
|
||||
_deleteItem(idx) {
|
||||
if (this.data.cols <= 1) return;
|
||||
for (let i = idx; i < this.data.cols; i++) {
|
||||
this._setItemData(i, this._getItemData(i + 1));
|
||||
}
|
||||
this._setItemData(this.data.cols, { type: 'html', col: '', subType: 'none' });
|
||||
this.data.cols = this.data.cols - 1;
|
||||
if (this.colsInput) this.colsInput.value = this.data.cols;
|
||||
this._renderColumnInputs();
|
||||
}
|
||||
|
||||
_showItem6DotsMenu(i, dragIcon) {
|
||||
document.querySelectorAll('.sis-item-6dots-menu').forEach(m => m.remove());
|
||||
|
||||
const menu = document.createElement('div');
|
||||
menu.className = 'sis-item-6dots-menu bg-white border rounded shadow-lg p-2';
|
||||
menu.style.position = 'absolute';
|
||||
menu.style.zIndex = '999999';
|
||||
menu.style.minWidth = '220px';
|
||||
menu.style.fontSize = '12px';
|
||||
menu.style.boxShadow = '0 8px 24px rgba(0,0,0,0.18)';
|
||||
|
||||
const rect = dragIcon.getBoundingClientRect();
|
||||
menu.style.top = (rect.bottom + window.scrollY + 4) + 'px';
|
||||
menu.style.left = (rect.left + window.scrollX) + 'px';
|
||||
|
||||
const header = document.createElement('div');
|
||||
header.className = 'font-weight-bold text-primary mb-2 pb-1 border-bottom d-flex justify-content-between align-items-center';
|
||||
header.innerHTML = `<span><i class="fas fa-ellipsis-v mr-1"></i> Tùy chọn Item ${i}</span><span style="cursor:pointer;" class="text-danger close-btn">×</span>`;
|
||||
header.querySelector('.close-btn').addEventListener('click', () => menu.remove());
|
||||
menu.appendChild(header);
|
||||
|
||||
// 1. Copy Item
|
||||
const copyBtn = document.createElement('button');
|
||||
copyBtn.type = 'button';
|
||||
copyBtn.className = 'btn btn-sm btn-light btn-block text-left mb-1';
|
||||
copyBtn.style.fontSize = '12px';
|
||||
copyBtn.innerHTML = '<i class="fas fa-copy text-info mr-2"></i> Sao chép Item này (Copy)';
|
||||
copyBtn.addEventListener('click', () => {
|
||||
const itemData = this._getItemData(i);
|
||||
const jsonStr = JSON.stringify(itemData);
|
||||
localStorage.setItem('sis_copied_item_data', jsonStr);
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard.writeText(jsonStr).catch(() => {});
|
||||
}
|
||||
copyBtn.className = 'btn btn-sm btn-success btn-block text-left mb-1';
|
||||
copyBtn.innerHTML = '<i class="fas fa-check mr-2"></i> Đã sao chép Item!';
|
||||
setTimeout(() => menu.remove(), 800);
|
||||
});
|
||||
menu.appendChild(copyBtn);
|
||||
|
||||
// 2. Unified Paste (Handles both inner Item copy and Editor.js Block copy)
|
||||
let dataToPaste = null;
|
||||
const copiedRawItem = localStorage.getItem('sis_copied_item_data');
|
||||
const copiedRawBlock = localStorage.getItem('sis_copied_editor_block');
|
||||
|
||||
if (copiedRawItem) {
|
||||
try {
|
||||
dataToPaste = JSON.parse(copiedRawItem);
|
||||
} catch (e) {}
|
||||
} else if (copiedRawBlock) {
|
||||
try {
|
||||
const b = JSON.parse(copiedRawBlock);
|
||||
if (b && b.type) {
|
||||
dataToPaste = {
|
||||
type: b.type,
|
||||
col: JSON.stringify(b.data || {}),
|
||||
subType: 'none',
|
||||
class: '',
|
||||
id: '',
|
||||
attr: '',
|
||||
width: 0
|
||||
};
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
if (dataToPaste) {
|
||||
// 2a. Paste into current item (overwrite)
|
||||
const pasteBtn = document.createElement('button');
|
||||
pasteBtn.type = 'button';
|
||||
pasteBtn.className = 'btn btn-sm btn-light btn-block text-left mb-1';
|
||||
pasteBtn.style.fontSize = '12px';
|
||||
pasteBtn.innerHTML = '<i class="fas fa-paste text-success mr-2"></i> Dán vào Item này (ghi đè)';
|
||||
pasteBtn.addEventListener('click', () => {
|
||||
this._setItemData(i, dataToPaste);
|
||||
pasteBtn.className = 'btn btn-sm btn-success btn-block text-left mb-1';
|
||||
pasteBtn.innerHTML = '<i class="fas fa-check mr-2"></i> Đã dán Item!';
|
||||
setTimeout(() => {
|
||||
menu.remove();
|
||||
this._renderColumnInputs();
|
||||
}, 600);
|
||||
});
|
||||
menu.appendChild(pasteBtn);
|
||||
|
||||
// 2b. Paste as new item inserted after current
|
||||
const pasteNewBtn = document.createElement('button');
|
||||
pasteNewBtn.type = 'button';
|
||||
pasteNewBtn.className = 'btn btn-sm btn-light btn-block text-left mb-1';
|
||||
pasteNewBtn.style.fontSize = '12px';
|
||||
pasteNewBtn.innerHTML = '<i class="fas fa-clone text-primary mr-2"></i> Dán thêm Item mới phía sau';
|
||||
if (this.data.cols >= 12) pasteNewBtn.disabled = true;
|
||||
pasteNewBtn.addEventListener('click', () => {
|
||||
if (this.data.cols >= 12) return;
|
||||
const newCols = this.data.cols + 1;
|
||||
for (let k = newCols; k > i + 1; k--) {
|
||||
this._setItemData(k, this._getItemData(k - 1));
|
||||
}
|
||||
this._setItemData(i + 1, dataToPaste);
|
||||
this.data.cols = newCols;
|
||||
if (this.colsInput) this.colsInput.value = newCols;
|
||||
pasteNewBtn.className = 'btn btn-sm btn-success btn-block text-left mb-1';
|
||||
pasteNewBtn.innerHTML = '<i class="fas fa-check mr-2"></i> Đã thêm Item mới!';
|
||||
setTimeout(() => {
|
||||
menu.remove();
|
||||
this._renderColumnInputs();
|
||||
}, 600);
|
||||
});
|
||||
menu.appendChild(pasteNewBtn);
|
||||
}
|
||||
|
||||
// 3. Move Left / Move Right
|
||||
const moveGroup = document.createElement('div');
|
||||
moveGroup.className = 'd-flex justify-content-between mb-1';
|
||||
|
||||
const moveLeft = document.createElement('button');
|
||||
moveLeft.type = 'button';
|
||||
moveLeft.className = 'btn btn-xs btn-outline-secondary flex-fill mr-1 py-1';
|
||||
moveLeft.style.fontSize = '11px';
|
||||
moveLeft.innerHTML = '<i class="fas fa-arrow-left"></i> Sang trái';
|
||||
if (i <= 1) moveLeft.disabled = true;
|
||||
moveLeft.addEventListener('click', () => {
|
||||
this._swapItems(i, i - 1);
|
||||
menu.remove();
|
||||
});
|
||||
moveGroup.appendChild(moveLeft);
|
||||
|
||||
const moveRight = document.createElement('button');
|
||||
moveRight.type = 'button';
|
||||
moveRight.className = 'btn btn-xs btn-outline-secondary flex-fill ml-1 py-1';
|
||||
moveRight.style.fontSize = '11px';
|
||||
moveRight.innerHTML = 'Sang phải <i class="fas fa-arrow-right"></i>';
|
||||
if (i >= this.data.cols) moveRight.disabled = true;
|
||||
moveRight.addEventListener('click', () => {
|
||||
this._swapItems(i, i + 1);
|
||||
menu.remove();
|
||||
});
|
||||
moveGroup.appendChild(moveRight);
|
||||
menu.appendChild(moveGroup);
|
||||
|
||||
// 4. Add Item After
|
||||
const addBtn = document.createElement('button');
|
||||
addBtn.type = 'button';
|
||||
addBtn.className = 'btn btn-sm btn-light btn-block text-left text-success mb-1';
|
||||
addBtn.style.fontSize = '12px';
|
||||
addBtn.innerHTML = '<i class="fas fa-plus-circle mr-2"></i> Thêm Item mới phía sau';
|
||||
if (this.data.cols >= 12) addBtn.disabled = true;
|
||||
addBtn.addEventListener('click', () => {
|
||||
this._addItem(i);
|
||||
menu.remove();
|
||||
});
|
||||
menu.appendChild(addBtn);
|
||||
|
||||
// 5. Delete Item
|
||||
const delBtn = document.createElement('button');
|
||||
delBtn.type = 'button';
|
||||
delBtn.className = 'btn btn-sm btn-light btn-block text-left text-danger mb-0';
|
||||
delBtn.style.fontSize = '12px';
|
||||
delBtn.innerHTML = '<i class="fas fa-trash-alt mr-2"></i> Xóa Item này';
|
||||
if (this.data.cols <= 1) delBtn.disabled = true;
|
||||
delBtn.addEventListener('click', () => {
|
||||
this._deleteItem(i);
|
||||
menu.remove();
|
||||
});
|
||||
menu.appendChild(delBtn);
|
||||
|
||||
document.body.appendChild(menu);
|
||||
|
||||
const closeHandler = (evt) => {
|
||||
if (!menu.contains(evt.target) && !dragIcon.contains(evt.target)) {
|
||||
menu.remove();
|
||||
document.removeEventListener('click', closeHandler);
|
||||
}
|
||||
};
|
||||
setTimeout(() => document.addEventListener('click', closeHandler), 50);
|
||||
}
|
||||
|
||||
_renderColumnInputs() {
|
||||
this.inputsContainer.innerHTML = '';
|
||||
|
||||
@@ -247,16 +535,108 @@ class FlexTool {
|
||||
|
||||
for (let i = 1; i <= count; i++) {
|
||||
const colDiv = document.createElement('div');
|
||||
colDiv.className = `${colWidthClass} mb-3 p-2 border rounded bg-white`;
|
||||
colDiv.className = `${colWidthClass} mb-3 p-2 border rounded bg-white ce-flex-item-card d-flex flex-column justify-content-between`;
|
||||
colDiv.style.boxShadow = '0 1px 3px rgba(0,0,0,0.05)';
|
||||
colDiv.style.transition = 'border-color 0.2s, box-shadow 0.2s';
|
||||
colDiv.draggable = true;
|
||||
|
||||
// HTML5 Drag & Drop handlers
|
||||
colDiv.addEventListener('dragstart', (e) => {
|
||||
e.dataTransfer.setData('text/plain', i);
|
||||
colDiv.style.opacity = '0.5';
|
||||
});
|
||||
colDiv.addEventListener('dragend', () => {
|
||||
colDiv.style.opacity = '1';
|
||||
});
|
||||
colDiv.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
colDiv.style.borderColor = '#007bff';
|
||||
colDiv.style.boxShadow = '0 0 0 2px rgba(0, 123, 255, 0.25)';
|
||||
});
|
||||
colDiv.addEventListener('dragleave', () => {
|
||||
colDiv.style.borderColor = '';
|
||||
colDiv.style.boxShadow = '0 1px 3px rgba(0,0,0,0.05)';
|
||||
});
|
||||
colDiv.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
colDiv.style.borderColor = '';
|
||||
colDiv.style.boxShadow = '0 1px 3px rgba(0,0,0,0.05)';
|
||||
const fromIdx = parseInt(e.dataTransfer.getData('text/plain'));
|
||||
if (fromIdx && fromIdx !== i) {
|
||||
this._swapItems(fromIdx, i);
|
||||
}
|
||||
});
|
||||
|
||||
const headerRow = document.createElement('div');
|
||||
headerRow.className = 'd-flex justify-content-between align-items-center mb-2 pb-1 border-bottom';
|
||||
|
||||
// Left Header: 6-dot drag icon, Item Label, Move Left/Right, Add Item (+), Delete (x)
|
||||
const labelWrapper = document.createElement('div');
|
||||
labelWrapper.className = 'd-flex align-items-center';
|
||||
|
||||
// 6-dot Drag Handle Icon
|
||||
const dragIcon = document.createElement('span');
|
||||
dragIcon.className = 'mr-2 text-muted sis-drag-handle-dots';
|
||||
dragIcon.style.cursor = 'grab';
|
||||
dragIcon.style.userSelect = 'none';
|
||||
dragIcon.style.display = 'inline-flex';
|
||||
dragIcon.style.alignItems = 'center';
|
||||
dragIcon.title = 'Kéo để sắp xếp, hoặc Click để mở menu chức năng đầy đủ';
|
||||
dragIcon.innerHTML = `<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" style="opacity: 0.65;"><circle cx="9" cy="5" r="2.2"/><circle cx="9" cy="12" r="2.2"/><circle cx="9" cy="19" r="2.2"/><circle cx="15" cy="5" r="2.2"/><circle cx="15" cy="12" r="2.2"/><circle cx="15" cy="19" r="2.2"/></svg>`;
|
||||
dragIcon.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this._showItem6DotsMenu(i, dragIcon);
|
||||
});
|
||||
|
||||
const label = document.createElement('label');
|
||||
label.className = 'small font-weight-bold text-dark mb-0';
|
||||
label.className = 'small font-weight-bold text-dark mb-0 mr-2';
|
||||
label.innerText = `Item ${i}`;
|
||||
headerRow.appendChild(label);
|
||||
|
||||
// Controls: Move Left (◀), Move Right (▶), + Item, Delete (✕)
|
||||
const moveLeftBtn = document.createElement('button');
|
||||
moveLeftBtn.type = 'button';
|
||||
moveLeftBtn.className = 'btn btn-xs btn-light border text-muted mr-1 px-1 py-0';
|
||||
moveLeftBtn.style.fontSize = '10px';
|
||||
moveLeftBtn.title = 'Move Left / Sang trái';
|
||||
moveLeftBtn.innerText = '◀';
|
||||
if (i === 1 || this.readOnly) moveLeftBtn.disabled = true;
|
||||
moveLeftBtn.addEventListener('click', () => this._swapItems(i, i - 1));
|
||||
|
||||
const moveRightBtn = document.createElement('button');
|
||||
moveRightBtn.type = 'button';
|
||||
moveRightBtn.className = 'btn btn-xs btn-light border text-muted mr-1 px-1 py-0';
|
||||
moveRightBtn.style.fontSize = '10px';
|
||||
moveRightBtn.title = 'Move Right / Sang phải';
|
||||
moveRightBtn.innerText = '▶';
|
||||
if (i === count || this.readOnly) moveRightBtn.disabled = true;
|
||||
moveRightBtn.addEventListener('click', () => this._swapItems(i, i + 1));
|
||||
|
||||
const addItemBtn = document.createElement('button');
|
||||
addItemBtn.type = 'button';
|
||||
addItemBtn.className = 'btn btn-xs btn-outline-success mr-1 px-1 py-0 font-weight-bold';
|
||||
addItemBtn.style.fontSize = '10px';
|
||||
addItemBtn.title = 'Insert new item after this / Thêm item mới';
|
||||
addItemBtn.innerText = '+ Item';
|
||||
if (count >= 12 || this.readOnly) addItemBtn.disabled = true;
|
||||
addItemBtn.addEventListener('click', () => this._addItem(i));
|
||||
|
||||
const deleteBtn = document.createElement('button');
|
||||
deleteBtn.type = 'button';
|
||||
deleteBtn.className = 'btn btn-xs btn-outline-danger px-1 py-0 font-weight-bold';
|
||||
deleteBtn.style.fontSize = '10px';
|
||||
deleteBtn.title = 'Delete item / Xóa item';
|
||||
deleteBtn.innerText = '✕';
|
||||
if (count <= 1 || this.readOnly) deleteBtn.disabled = true;
|
||||
deleteBtn.addEventListener('click', () => this._deleteItem(i));
|
||||
|
||||
labelWrapper.appendChild(dragIcon);
|
||||
labelWrapper.appendChild(label);
|
||||
labelWrapper.appendChild(moveLeftBtn);
|
||||
labelWrapper.appendChild(moveRightBtn);
|
||||
labelWrapper.appendChild(addItemBtn);
|
||||
labelWrapper.appendChild(deleteBtn);
|
||||
|
||||
headerRow.appendChild(labelWrapper);
|
||||
|
||||
const controlsWrapper = document.createElement('div');
|
||||
controlsWrapper.className = 'd-flex align-items-center gap-1';
|
||||
@@ -311,8 +691,12 @@ class FlexTool {
|
||||
];
|
||||
|
||||
if (window.SISEditorPlugins) {
|
||||
const seenPlugins = new Set();
|
||||
Object.keys(window.SISEditorPlugins).forEach(key => {
|
||||
// Allow nesting any registered plugins (no exclusions)
|
||||
const entry = window.SISEditorPlugins[key];
|
||||
const pluginObj = entry ? (entry.class || entry) : null;
|
||||
if (pluginObj && seenPlugins.has(pluginObj)) return;
|
||||
if (pluginObj) seenPlugins.add(pluginObj);
|
||||
if (!types.some(t => t.value === key)) {
|
||||
types.push({ value: key, label: `[Plugin] ${key}` });
|
||||
}
|
||||
@@ -328,6 +712,7 @@ class FlexTool {
|
||||
});
|
||||
|
||||
const contentDiv = document.createElement('div');
|
||||
contentDiv.className = 'd-flex flex-column flex-fill';
|
||||
|
||||
typeSelect.addEventListener('change', (e) => {
|
||||
this.data[`type${i}`] = e.target.value;
|
||||
@@ -369,7 +754,12 @@ class FlexTool {
|
||||
{ value: 'accordion', label: 'Accordion' }
|
||||
];
|
||||
if (window.SISEditorPlugins) {
|
||||
const seenSubPlugins = new Set();
|
||||
Object.keys(window.SISEditorPlugins).forEach(key => {
|
||||
const entry = window.SISEditorPlugins[key];
|
||||
const pluginObj = entry ? (entry.class || entry) : null;
|
||||
if (pluginObj && seenSubPlugins.has(pluginObj)) return;
|
||||
if (pluginObj) seenSubPlugins.add(pluginObj);
|
||||
if (!subTypes.some(t => t.value === key)) {
|
||||
subTypes.push({ value: key, label: `[Plugin] ${key}` });
|
||||
}
|
||||
@@ -432,6 +822,18 @@ class FlexTool {
|
||||
renderSubInputs();
|
||||
}
|
||||
|
||||
if (count < 12 && !this.readOnly) {
|
||||
const addCardCol = document.createElement('div');
|
||||
addCardCol.className = 'col-12 mt-1 text-center';
|
||||
const addCardBtn = document.createElement('button');
|
||||
addCardBtn.type = 'button';
|
||||
addCardBtn.className = 'btn btn-sm btn-outline-primary font-weight-bold px-3 py-1 shadow-sm';
|
||||
addCardBtn.innerHTML = '<i class="fas fa-plus mr-1"></i> + Thêm Item Mới';
|
||||
addCardBtn.addEventListener('click', () => this._addItem(count));
|
||||
addCardCol.appendChild(addCardBtn);
|
||||
row.appendChild(addCardCol);
|
||||
}
|
||||
|
||||
this.inputsContainer.appendChild(row);
|
||||
}
|
||||
|
||||
@@ -444,7 +846,7 @@ class FlexTool {
|
||||
}
|
||||
|
||||
const typeContainer = document.createElement('div');
|
||||
typeContainer.className = 'mb-3';
|
||||
typeContainer.className = 'mb-3 d-flex flex-column flex-fill';
|
||||
container.appendChild(typeContainer);
|
||||
|
||||
const type = explicitType || this.data[`type${i}`] || 'html';
|
||||
@@ -543,10 +945,26 @@ class FlexTool {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (window.SISMediaPicker) {
|
||||
const currentUrl = (this.data[`col${i}`] && this.data[`col${i}`].file) ? this.data[`col${i}`].file.url : (this.data[`imgUrl${i}`] || '');
|
||||
const currentConfig = {
|
||||
style: this.data[`imgStyle${i}`] || '',
|
||||
cssClass: this.data[`imgClass${i}`] || '',
|
||||
alt: this.data[`imgAlt${i}`] || '',
|
||||
customAttributes: this.data[`imgAttrs${i}`] || '',
|
||||
aspectRatio: this.data[`imgAspectRatio${i}`] || ''
|
||||
};
|
||||
SISMediaPicker.open((url, mediaObj, config) => {
|
||||
if (url) {
|
||||
const fileObj = {
|
||||
url: url,
|
||||
style: (config && config.style) || '',
|
||||
cssClass: (config && config.cssClass) || '',
|
||||
alt: (config && config.alt) || '',
|
||||
customAttributes: (config && config.customAttributes) || '',
|
||||
aspectRatio: (config && config.aspectRatio) || ''
|
||||
};
|
||||
this.data[`col${i}`] = {
|
||||
file: { url: url },
|
||||
file: fileObj,
|
||||
caption: (config && config.alt) || '',
|
||||
withBorder: false,
|
||||
withBackground: false,
|
||||
@@ -561,7 +979,7 @@ class FlexTool {
|
||||
}
|
||||
this._renderTypeSpecificInput(i, container);
|
||||
}
|
||||
});
|
||||
}, currentUrl, currentConfig);
|
||||
} else {
|
||||
alert('Media Library is not yet configured on this system.');
|
||||
}
|
||||
@@ -572,11 +990,13 @@ class FlexTool {
|
||||
if (clearBtn) {
|
||||
clearBtn.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (confirm('Are you sure you want to remove this image?')) {
|
||||
this.data[`col${i}`] = '';
|
||||
this._renderTypeSpecificInput(i, container);
|
||||
}
|
||||
delete this.data[`col${i}`];
|
||||
delete this.data[`imgStyle${i}`];
|
||||
delete this.data[`imgClass${i}`];
|
||||
delete this.data[`imgAlt${i}`];
|
||||
delete this.data[`imgAttrs${i}`];
|
||||
delete this.data[`imgAspectRatio${i}`];
|
||||
this._renderTypeSpecificInput(i, container);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -643,6 +1063,14 @@ class FlexTool {
|
||||
chooseBtn.innerText = 'Choose';
|
||||
if (this.readOnly) chooseBtn.disabled = true;
|
||||
chooseBtn.addEventListener('click', () => {
|
||||
const currentUrl = this.data[`imgUrl${i}`] || '';
|
||||
const currentConfig = {
|
||||
style: this.data[`imgStyle${i}`] || '',
|
||||
cssClass: this.data[`imgClass${i}`] || '',
|
||||
alt: this.data[`imgAlt${i}`] || '',
|
||||
customAttributes: this.data[`imgAttrs${i}`] || '',
|
||||
aspectRatio: this.data[`imgAspectRatio${i}`] || ''
|
||||
};
|
||||
if (window.SISMediaPicker) {
|
||||
SISMediaPicker.open((selectedUrl, mediaObj, config) => {
|
||||
input.value = selectedUrl;
|
||||
@@ -656,7 +1084,7 @@ class FlexTool {
|
||||
}
|
||||
previewImg.src = selectedUrl;
|
||||
previewBox.style.display = 'block';
|
||||
});
|
||||
}, currentUrl, currentConfig);
|
||||
} else {
|
||||
alert('Media Picker modal helper is not loaded.');
|
||||
}
|
||||
@@ -742,23 +1170,135 @@ class FlexTool {
|
||||
});
|
||||
|
||||
} else if (type === 'youtube') {
|
||||
const label = document.createElement('label');
|
||||
label.className = 'font-weight-bold text-danger small mb-1';
|
||||
label.innerHTML = '<i class="fab fa-youtube"></i> Đường dẫn YouTube Video (URL / ID)';
|
||||
|
||||
const input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
input.className = 'form-control form-control-sm mb-2';
|
||||
input.placeholder = 'Paste YouTube video URL...';
|
||||
input.placeholder = 'Paste YouTube video URL (e.g. https://www.youtube.com/watch?v=sDE4ZZdNOOY)...';
|
||||
input.value = this.data[`ytUrl${i}`] || '';
|
||||
if (this.readOnly) input.disabled = true;
|
||||
|
||||
const preview = document.createElement('div');
|
||||
preview.className = 'small text-muted p-2 bg-light border rounded';
|
||||
preview.innerText = 'Format: https://www.youtube.com/watch?v=XXXXXX';
|
||||
// Custom Thumbnail Group
|
||||
const thumbLabel = document.createElement('label');
|
||||
thumbLabel.className = 'font-weight-bold text-secondary small mb-1';
|
||||
thumbLabel.innerHTML = '<i class="fas fa-image"></i> Ảnh bìa Custom Thumbnail (Tùy chọn - Đè lên bìa YouTube)';
|
||||
|
||||
const thumbGroup = document.createElement('div');
|
||||
thumbGroup.className = 'input-group input-group-sm mb-2';
|
||||
|
||||
const thumbInput = document.createElement('input');
|
||||
thumbInput.type = 'text';
|
||||
thumbInput.className = 'form-control';
|
||||
thumbInput.placeholder = 'URL ảnh bìa hoặc chọn từ Thư viện Media...';
|
||||
thumbInput.value = this.data[`ytThumbUrl${i}`] || this.data[`imgUrl${i}`] || '';
|
||||
if (this.readOnly) thumbInput.disabled = true;
|
||||
|
||||
const thumbAppend = document.createElement('div');
|
||||
thumbAppend.className = 'input-group-append';
|
||||
|
||||
const mediaBtn = document.createElement('button');
|
||||
mediaBtn.type = 'button';
|
||||
mediaBtn.className = 'btn btn-outline-primary font-weight-bold';
|
||||
mediaBtn.innerHTML = '<i class="fas fa-images mr-1"></i> Thư viện Media';
|
||||
if (this.readOnly) mediaBtn.disabled = true;
|
||||
|
||||
mediaBtn.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
if (window.SISMediaPicker) {
|
||||
SISMediaPicker.open((selectedUrl) => {
|
||||
thumbInput.value = selectedUrl;
|
||||
this.data[`ytThumbUrl${i}`] = selectedUrl;
|
||||
this.data[`imgUrl${i}`] = selectedUrl;
|
||||
updatePreview();
|
||||
});
|
||||
} else {
|
||||
alert('Thư viện Media (SISMediaPicker) chưa được tải.');
|
||||
}
|
||||
});
|
||||
|
||||
thumbAppend.appendChild(mediaBtn);
|
||||
thumbGroup.appendChild(thumbInput);
|
||||
thumbGroup.appendChild(thumbAppend);
|
||||
|
||||
// Stretch / Full-width Switch
|
||||
const stretchWrapper = document.createElement('div');
|
||||
stretchWrapper.className = 'custom-control custom-switch mb-2';
|
||||
|
||||
const stretchCheck = document.createElement('input');
|
||||
stretchCheck.type = 'checkbox';
|
||||
stretchCheck.className = 'custom-control-input';
|
||||
const switchId = 'yt_item_stretch_' + i + '_' + Math.random().toString(36).substring(7);
|
||||
stretchCheck.id = switchId;
|
||||
stretchCheck.checked = !!(this.data[`ytStretched${i}`]);
|
||||
if (this.readOnly) stretchCheck.disabled = true;
|
||||
|
||||
const stretchLabel = document.createElement('label');
|
||||
stretchLabel.className = 'custom-control-label small font-weight-bold text-dark';
|
||||
stretchLabel.htmlFor = switchId;
|
||||
stretchLabel.style.cursor = 'pointer';
|
||||
stretchLabel.innerHTML = '<i class="fas fa-arrows-alt-h text-primary"></i> Ép full chiều rộng (Force Full Width)';
|
||||
|
||||
stretchCheck.addEventListener('change', () => {
|
||||
this.data[`ytStretched${i}`] = stretchCheck.checked;
|
||||
});
|
||||
|
||||
stretchWrapper.appendChild(stretchCheck);
|
||||
stretchWrapper.appendChild(stretchLabel);
|
||||
|
||||
// Thumbnail Preview Box
|
||||
const previewBox = document.createElement('div');
|
||||
previewBox.className = 'yt-preview-card p-2 bg-light border rounded text-center mb-2';
|
||||
previewBox.style.minHeight = '60px';
|
||||
previewBox.style.borderRadius = '6px';
|
||||
previewBox.style.background = '#111 center/cover no-repeat';
|
||||
previewBox.style.position = 'relative';
|
||||
|
||||
const updatePreview = () => {
|
||||
const url = input.value.trim();
|
||||
const customThumb = thumbInput.value.trim();
|
||||
let videoId = '';
|
||||
var regExp = /^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|\&v=)([^#\&\?]*).*/;
|
||||
var match = url.match(regExp);
|
||||
if (match && match[2].length === 11) videoId = match[2];
|
||||
else if (url.length === 11) videoId = url;
|
||||
|
||||
const thumbUrl = customThumb || (videoId ? 'https://img.youtube.com/vi/' + videoId + '/hqdefault.jpg' : '');
|
||||
if (thumbUrl) {
|
||||
previewBox.style.backgroundImage = 'url("' + thumbUrl + '")';
|
||||
previewBox.style.paddingBottom = '56.25%';
|
||||
previewBox.style.height = '0';
|
||||
previewBox.style.display = 'block';
|
||||
} else {
|
||||
previewBox.style.backgroundImage = 'none';
|
||||
previewBox.style.paddingBottom = '0';
|
||||
previewBox.style.height = 'auto';
|
||||
previewBox.innerText = 'Format: https://www.youtube.com/watch?v=XXXXXX';
|
||||
}
|
||||
};
|
||||
|
||||
input.addEventListener('input', (e) => {
|
||||
this.data[`ytUrl${i}`] = e.target.value.trim();
|
||||
updatePreview();
|
||||
});
|
||||
|
||||
thumbInput.addEventListener('input', (e) => {
|
||||
const val = e.target.value.trim();
|
||||
this.data[`ytThumbUrl${i}`] = val;
|
||||
this.data[`imgUrl${i}`] = val;
|
||||
updatePreview();
|
||||
});
|
||||
|
||||
updatePreview();
|
||||
|
||||
typeContainer.appendChild(label);
|
||||
typeContainer.appendChild(input);
|
||||
typeContainer.appendChild(preview);
|
||||
typeContainer.appendChild(thumbLabel);
|
||||
typeContainer.appendChild(thumbGroup);
|
||||
typeContainer.appendChild(stretchWrapper);
|
||||
typeContainer.appendChild(previewBox);
|
||||
|
||||
} else if (type === 'accordion') {
|
||||
const titleInput = document.createElement('input');
|
||||
@@ -861,7 +1401,14 @@ class FlexTool {
|
||||
}
|
||||
|
||||
savedData[`imgUrl${i}`] = this.data[`imgUrl${i}`] || '';
|
||||
savedData[`imgId${i}`] = this.data[`imgId${i}`] || (this.data[`col${i}`] && this.data[`col${i}`].file ? (this.data[`col${i}`].file.id || this.data[`col${i}`].file.elementId) : '');
|
||||
savedData[`imgStyle${i}`] = this.data[`imgStyle${i}`] || (this.data[`col${i}`] && this.data[`col${i}`].file ? this.data[`col${i}`].file.style : '');
|
||||
savedData[`imgClass${i}`] = this.data[`imgClass${i}`] || (this.data[`col${i}`] && this.data[`col${i}`].file ? this.data[`col${i}`].file.cssClass : '');
|
||||
savedData[`imgAlt${i}`] = this.data[`imgAlt${i}`] || (this.data[`col${i}`] && this.data[`col${i}`].file ? this.data[`col${i}`].file.alt : '');
|
||||
savedData[`imgAttrs${i}`] = this.data[`imgAttrs${i}`] || (this.data[`col${i}`] && this.data[`col${i}`].file ? this.data[`col${i}`].file.customAttributes : '');
|
||||
savedData[`ytUrl${i}`] = this.data[`ytUrl${i}`] || '';
|
||||
savedData[`ytThumbUrl${i}`] = this.data[`ytThumbUrl${i}`] || '';
|
||||
savedData[`ytStretched${i}`] = !!this.data[`ytStretched${i}`];
|
||||
savedData[`accTitle${i}`] = this.data[`accTitle${i}`] || '';
|
||||
savedData[`accContent${i}`] = this.data[`accContent${i}`] || '';
|
||||
savedData[`class${i}`] = this.data[`class${i}`] || '';
|
||||
@@ -888,6 +1435,8 @@ class FlexTool {
|
||||
|
||||
savedData[`imgUrlsub_${i}`] = this.data[`imgUrlsub_${i}`] || '';
|
||||
savedData[`ytUrlsub_${i}`] = this.data[`ytUrlsub_${i}`] || '';
|
||||
savedData[`ytThumbUrlsub_${i}`] = this.data[`ytThumbUrlsub_${i}`] || '';
|
||||
savedData[`ytStretchedsub_${i}`] = !!this.data[`ytStretchedsub_${i}`];
|
||||
savedData[`accTitlesub_${i}`] = this.data[`accTitlesub_${i}`] || '';
|
||||
savedData[`accContentsub_${i}`] = this.data[`accContentsub_${i}`] || '';
|
||||
savedData[`classsub_${i}`] = this.data[`classsub_${i}`] || '';
|
||||
|
||||
@@ -68,15 +68,21 @@ class GridTool {
|
||||
|
||||
// 1. Columns
|
||||
const colDiv = document.createElement('div');
|
||||
colDiv.className = 'col-md-2 mb-2';
|
||||
colDiv.className = 'col-md-3 mb-2';
|
||||
colDiv.innerHTML = '<label class="small font-weight-bold text-secondary mb-1">Columns</label>';
|
||||
|
||||
const colControlGroup = document.createElement('div');
|
||||
colControlGroup.className = 'd-flex align-items-center';
|
||||
|
||||
const colInput = document.createElement('input');
|
||||
colInput.type = 'number';
|
||||
colInput.className = 'form-control form-control-sm';
|
||||
colInput.className = 'form-control form-control-sm mr-1';
|
||||
colInput.style.width = '65px';
|
||||
colInput.min = '1';
|
||||
colInput.max = '12';
|
||||
colInput.value = this.data.cols;
|
||||
if (this.readOnly) colInput.disabled = true;
|
||||
this.colsInput = colInput;
|
||||
colInput.addEventListener('input', (e) => {
|
||||
let val = parseInt(e.target.value) || 2;
|
||||
if (val < 1) val = 1;
|
||||
@@ -84,7 +90,18 @@ class GridTool {
|
||||
this.data.cols = val;
|
||||
this._renderColumnInputs();
|
||||
});
|
||||
colDiv.appendChild(colInput);
|
||||
colControlGroup.appendChild(colInput);
|
||||
|
||||
const addColBtn = document.createElement('button');
|
||||
addColBtn.type = 'button';
|
||||
addColBtn.className = 'btn btn-sm btn-outline-primary font-weight-bold';
|
||||
addColBtn.title = 'Add new Column';
|
||||
addColBtn.innerHTML = '<i class="fas fa-plus"></i> + Col';
|
||||
if (this.readOnly) addColBtn.disabled = true;
|
||||
addColBtn.addEventListener('click', () => this._addItem(this.data.cols));
|
||||
colControlGroup.appendChild(addColBtn);
|
||||
|
||||
colDiv.appendChild(colControlGroup);
|
||||
settingsRow.appendChild(colDiv);
|
||||
|
||||
// 2. Alignment Dropdown option (Left, Center, Right, Space Between, Space Around)
|
||||
@@ -156,7 +173,7 @@ class GridTool {
|
||||
|
||||
// 5. Global HTML ID
|
||||
const idDiv = document.createElement('div');
|
||||
idDiv.className = 'col-md-3 mb-2';
|
||||
idDiv.className = 'col-md-2 mb-2';
|
||||
idDiv.innerHTML = '<label class="small font-weight-bold text-secondary mb-1">Grid HTML ID</label>';
|
||||
const idInput = document.createElement('input');
|
||||
idInput.type = 'text';
|
||||
@@ -195,6 +212,277 @@ class GridTool {
|
||||
return this.wrapper;
|
||||
}
|
||||
|
||||
_getItemData(i) {
|
||||
return {
|
||||
type: this.data[`type${i}`] || 'html',
|
||||
col: this.data[`col${i}`] || '',
|
||||
imgUrl: this.data[`imgUrl${i}`] || '',
|
||||
ytUrl: this.data[`ytUrl${i}`] || '',
|
||||
ytThumbUrl: this.data[`ytThumbUrl${i}`] || '',
|
||||
ytStretched: !!this.data[`ytStretched${i}`],
|
||||
accTitle: this.data[`accTitle${i}`] || '',
|
||||
accContent: this.data[`accContent${i}`] || '',
|
||||
class: this.data[`class${i}`] || '',
|
||||
id: this.data[`id${i}`] || '',
|
||||
attr: this.data[`attr${i}`] || '',
|
||||
width: this.data[`width${i}`] || 0,
|
||||
caption: this.data[`caption${i}`] || '',
|
||||
subType: this.data[`subType${i}`] || 'none',
|
||||
colsub_: this.data[`colsub_${i}`] || '',
|
||||
imgUrlsub_: this.data[`imgUrlsub_${i}`] || '',
|
||||
ytUrlsub_: this.data[`ytUrlsub_${i}`] || '',
|
||||
ytThumbUrlsub_: this.data[`ytThumbUrlsub_${i}`] || '',
|
||||
ytStretchedsub_: !!this.data[`ytStretchedsub_${i}`],
|
||||
accTitlesub_: this.data[`accTitlesub_${i}`] || '',
|
||||
accContentsub_: this.data[`accContentsub_${i}`] || '',
|
||||
classsub_: this.data[`classsub_${i}`] || '',
|
||||
idsub_: this.data[`idsub_${i}`] || '',
|
||||
attrsub_: this.data[`attrsub_${i}`] || ''
|
||||
};
|
||||
}
|
||||
|
||||
_setItemData(i, obj) {
|
||||
this.data[`type${i}`] = obj.type || 'html';
|
||||
let colVal = obj.col !== undefined && obj.col !== null ? obj.col : '';
|
||||
if (typeof colVal === 'object') {
|
||||
colVal = JSON.stringify(colVal);
|
||||
}
|
||||
this.data[`col${i}`] = colVal;
|
||||
this.data[`imgUrl${i}`] = obj.imgUrl || '';
|
||||
this.data[`ytUrl${i}`] = obj.ytUrl || '';
|
||||
this.data[`ytThumbUrl${i}`] = obj.ytThumbUrl || obj.imgUrl || '';
|
||||
this.data[`ytStretched${i}`] = !!obj.ytStretched;
|
||||
this.data[`accTitle${i}`] = obj.accTitle || '';
|
||||
this.data[`accContent${i}`] = obj.accContent || '';
|
||||
this.data[`class${i}`] = obj.class || '';
|
||||
this.data[`id${i}`] = obj.id || '';
|
||||
this.data[`attr${i}`] = obj.attr || '';
|
||||
this.data[`width${i}`] = obj.width || 0;
|
||||
this.data[`caption${i}`] = obj.caption || '';
|
||||
this.data[`subType${i}`] = obj.subType || 'none';
|
||||
let colsubVal = obj.colsub_ !== undefined && obj.colsub_ !== null ? obj.colsub_ : '';
|
||||
if (typeof colsubVal === 'object') {
|
||||
colsubVal = JSON.stringify(colsubVal);
|
||||
}
|
||||
this.data[`colsub_${i}`] = colsubVal;
|
||||
this.data[`imgUrlsub_${i}`] = obj.imgUrlsub_ || '';
|
||||
this.data[`ytUrlsub_${i}`] = obj.ytUrlsub_ || '';
|
||||
this.data[`ytThumbUrlsub_${i}`] = obj.ytThumbUrlsub_ || obj.imgUrlsub_ || '';
|
||||
this.data[`ytStretchedsub_${i}`] = !!obj.ytStretchedsub_;
|
||||
this.data[`accTitlesub_${i}`] = obj.accTitlesub_ || '';
|
||||
this.data[`accContentsub_${i}`] = obj.accContentsub_ || '';
|
||||
this.data[`classsub_${i}`] = obj.classsub_ || '';
|
||||
this.data[`idsub_${i}`] = obj.idsub_ || '';
|
||||
this.data[`attrsub_${i}`] = obj.attrsub_ || '';
|
||||
}
|
||||
|
||||
_swapItems(i, j) {
|
||||
const itemI = this._getItemData(i);
|
||||
const itemJ = this._getItemData(j);
|
||||
this._setItemData(i, itemJ);
|
||||
this._setItemData(j, itemI);
|
||||
this._renderColumnInputs();
|
||||
}
|
||||
|
||||
_addItem(afterIdx) {
|
||||
if (this.data.cols >= 12) return;
|
||||
const newCols = this.data.cols + 1;
|
||||
for (let i = newCols; i > afterIdx + 1; i--) {
|
||||
this._setItemData(i, this._getItemData(i - 1));
|
||||
}
|
||||
this._setItemData(afterIdx + 1, { type: 'html', col: '', subType: 'none' });
|
||||
this.data.cols = newCols;
|
||||
if (this.colsInput) this.colsInput.value = newCols;
|
||||
this._renderColumnInputs();
|
||||
}
|
||||
|
||||
_deleteItem(idx) {
|
||||
if (this.data.cols <= 1) return;
|
||||
for (let i = idx; i < this.data.cols; i++) {
|
||||
this._setItemData(i, this._getItemData(i + 1));
|
||||
}
|
||||
this._setItemData(this.data.cols, { type: 'html', col: '', subType: 'none' });
|
||||
this.data.cols = this.data.cols - 1;
|
||||
if (this.colsInput) this.colsInput.value = this.data.cols;
|
||||
this._renderColumnInputs();
|
||||
}
|
||||
|
||||
_showItem6DotsMenu(i, dragIcon) {
|
||||
document.querySelectorAll('.sis-item-6dots-menu').forEach(m => m.remove());
|
||||
|
||||
const menu = document.createElement('div');
|
||||
menu.className = 'sis-item-6dots-menu bg-white border rounded shadow-lg p-2';
|
||||
menu.style.position = 'absolute';
|
||||
menu.style.zIndex = '999999';
|
||||
menu.style.minWidth = '220px';
|
||||
menu.style.fontSize = '12px';
|
||||
menu.style.boxShadow = '0 8px 24px rgba(0,0,0,0.18)';
|
||||
|
||||
const rect = dragIcon.getBoundingClientRect();
|
||||
menu.style.top = (rect.bottom + window.scrollY + 4) + 'px';
|
||||
menu.style.left = (rect.left + window.scrollX) + 'px';
|
||||
|
||||
const header = document.createElement('div');
|
||||
header.className = 'font-weight-bold text-primary mb-2 pb-1 border-bottom d-flex justify-content-between align-items-center';
|
||||
header.innerHTML = `<span><i class="fas fa-ellipsis-v mr-1"></i> Tùy chọn Cột ${i}</span><span style="cursor:pointer;" class="text-danger close-btn">×</span>`;
|
||||
header.querySelector('.close-btn').addEventListener('click', () => menu.remove());
|
||||
menu.appendChild(header);
|
||||
|
||||
// 1. Copy Item
|
||||
const copyBtn = document.createElement('button');
|
||||
copyBtn.type = 'button';
|
||||
copyBtn.className = 'btn btn-sm btn-light btn-block text-left mb-1';
|
||||
copyBtn.style.fontSize = '12px';
|
||||
copyBtn.innerHTML = '<i class="fas fa-copy text-info mr-2"></i> Sao chép Cột này (Copy)';
|
||||
copyBtn.addEventListener('click', () => {
|
||||
const itemData = this._getItemData(i);
|
||||
const jsonStr = JSON.stringify(itemData);
|
||||
localStorage.setItem('sis_copied_item_data', jsonStr);
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard.writeText(jsonStr).catch(() => {});
|
||||
}
|
||||
copyBtn.className = 'btn btn-sm btn-success btn-block text-left mb-1';
|
||||
copyBtn.innerHTML = '<i class="fas fa-check mr-2"></i> Đã sao chép Cột!';
|
||||
setTimeout(() => menu.remove(), 800);
|
||||
});
|
||||
menu.appendChild(copyBtn);
|
||||
|
||||
// 2. Unified Paste (Handles both inner Item copy and Editor.js Block copy)
|
||||
let dataToPaste = null;
|
||||
const copiedRawItem = localStorage.getItem('sis_copied_item_data');
|
||||
const copiedRawBlock = localStorage.getItem('sis_copied_editor_block');
|
||||
|
||||
if (copiedRawItem) {
|
||||
try {
|
||||
dataToPaste = JSON.parse(copiedRawItem);
|
||||
} catch (e) {}
|
||||
} else if (copiedRawBlock) {
|
||||
try {
|
||||
const b = JSON.parse(copiedRawBlock);
|
||||
if (b && b.type) {
|
||||
dataToPaste = {
|
||||
type: b.type,
|
||||
col: JSON.stringify(b.data || {}),
|
||||
subType: 'none',
|
||||
class: '',
|
||||
id: '',
|
||||
attr: '',
|
||||
width: 0
|
||||
};
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
if (dataToPaste) {
|
||||
// 2a. Paste into current column (overwrite)
|
||||
const pasteBtn = document.createElement('button');
|
||||
pasteBtn.type = 'button';
|
||||
pasteBtn.className = 'btn btn-sm btn-light btn-block text-left mb-1';
|
||||
pasteBtn.style.fontSize = '12px';
|
||||
pasteBtn.innerHTML = '<i class="fas fa-paste text-success mr-2"></i> Dán vào Cột này (ghi đè)';
|
||||
pasteBtn.addEventListener('click', () => {
|
||||
this._setItemData(i, dataToPaste);
|
||||
pasteBtn.className = 'btn btn-sm btn-success btn-block text-left mb-1';
|
||||
pasteBtn.innerHTML = '<i class="fas fa-check mr-2"></i> Đã dán Cột!';
|
||||
setTimeout(() => {
|
||||
menu.remove();
|
||||
this._renderColumnInputs();
|
||||
}, 600);
|
||||
});
|
||||
menu.appendChild(pasteBtn);
|
||||
|
||||
// 2b. Paste as new column inserted after current
|
||||
const pasteNewBtn = document.createElement('button');
|
||||
pasteNewBtn.type = 'button';
|
||||
pasteNewBtn.className = 'btn btn-sm btn-light btn-block text-left mb-1';
|
||||
pasteNewBtn.style.fontSize = '12px';
|
||||
pasteNewBtn.innerHTML = '<i class="fas fa-clone text-primary mr-2"></i> Dán thêm Cột mới phía sau';
|
||||
if (this.data.cols >= 12) pasteNewBtn.disabled = true;
|
||||
pasteNewBtn.addEventListener('click', () => {
|
||||
if (this.data.cols >= 12) return;
|
||||
const newCols = this.data.cols + 1;
|
||||
for (let k = newCols; k > i + 1; k--) {
|
||||
this._setItemData(k, this._getItemData(k - 1));
|
||||
}
|
||||
this._setItemData(i + 1, dataToPaste);
|
||||
this.data.cols = newCols;
|
||||
if (this.colsInput) this.colsInput.value = newCols;
|
||||
pasteNewBtn.className = 'btn btn-sm btn-success btn-block text-left mb-1';
|
||||
pasteNewBtn.innerHTML = '<i class="fas fa-check mr-2"></i> Đã thêm Cột mới!';
|
||||
setTimeout(() => {
|
||||
menu.remove();
|
||||
this._renderColumnInputs();
|
||||
}, 600);
|
||||
});
|
||||
menu.appendChild(pasteNewBtn);
|
||||
}
|
||||
|
||||
// 3. Move Left / Move Right
|
||||
const moveGroup = document.createElement('div');
|
||||
moveGroup.className = 'd-flex justify-content-between mb-1';
|
||||
|
||||
const moveLeft = document.createElement('button');
|
||||
moveLeft.type = 'button';
|
||||
moveLeft.className = 'btn btn-xs btn-outline-secondary flex-fill mr-1 py-1';
|
||||
moveLeft.style.fontSize = '11px';
|
||||
moveLeft.innerHTML = '<i class="fas fa-arrow-left"></i> Sang trái';
|
||||
if (i <= 1) moveLeft.disabled = true;
|
||||
moveLeft.addEventListener('click', () => {
|
||||
this._swapItems(i, i - 1);
|
||||
menu.remove();
|
||||
});
|
||||
moveGroup.appendChild(moveLeft);
|
||||
|
||||
const moveRight = document.createElement('button');
|
||||
moveRight.type = 'button';
|
||||
moveRight.className = 'btn btn-xs btn-outline-secondary flex-fill ml-1 py-1';
|
||||
moveRight.style.fontSize = '11px';
|
||||
moveRight.innerHTML = 'Sang phải <i class="fas fa-arrow-right"></i>';
|
||||
if (i >= this.data.cols) moveRight.disabled = true;
|
||||
moveRight.addEventListener('click', () => {
|
||||
this._swapItems(i, i + 1);
|
||||
menu.remove();
|
||||
});
|
||||
moveGroup.appendChild(moveRight);
|
||||
menu.appendChild(moveGroup);
|
||||
|
||||
// 4. Add Col After
|
||||
const addBtn = document.createElement('button');
|
||||
addBtn.type = 'button';
|
||||
addBtn.className = 'btn btn-sm btn-light btn-block text-left text-success mb-1';
|
||||
addBtn.style.fontSize = '12px';
|
||||
addBtn.innerHTML = '<i class="fas fa-plus-circle mr-2"></i> Thêm Cột mới phía sau';
|
||||
if (this.data.cols >= 12) addBtn.disabled = true;
|
||||
addBtn.addEventListener('click', () => {
|
||||
this._addItem(i);
|
||||
menu.remove();
|
||||
});
|
||||
menu.appendChild(addBtn);
|
||||
|
||||
// 5. Delete Col
|
||||
const delBtn = document.createElement('button');
|
||||
delBtn.type = 'button';
|
||||
delBtn.className = 'btn btn-sm btn-light btn-block text-left text-danger mb-0';
|
||||
delBtn.style.fontSize = '12px';
|
||||
delBtn.innerHTML = '<i class="fas fa-trash-alt mr-2"></i> Xóa Cột này';
|
||||
if (this.data.cols <= 1) delBtn.disabled = true;
|
||||
delBtn.addEventListener('click', () => {
|
||||
this._deleteItem(i);
|
||||
menu.remove();
|
||||
});
|
||||
menu.appendChild(delBtn);
|
||||
|
||||
document.body.appendChild(menu);
|
||||
|
||||
const closeHandler = (evt) => {
|
||||
if (!menu.contains(evt.target) && !dragIcon.contains(evt.target)) {
|
||||
menu.remove();
|
||||
document.removeEventListener('click', closeHandler);
|
||||
}
|
||||
};
|
||||
setTimeout(() => document.addEventListener('click', closeHandler), 50);
|
||||
}
|
||||
|
||||
_renderColumnInputs() {
|
||||
this.inputsContainer.innerHTML = '';
|
||||
|
||||
@@ -206,19 +494,112 @@ class GridTool {
|
||||
|
||||
for (let i = 1; i <= count; i++) {
|
||||
const colDiv = document.createElement('div');
|
||||
colDiv.className = `${colWidthClass} mb-3 p-2 border rounded bg-white`;
|
||||
colDiv.className = `${colWidthClass} mb-3 p-2 border rounded bg-white ce-grid-item-card`;
|
||||
colDiv.style.boxShadow = '0 1px 3px rgba(0,0,0,0.05)';
|
||||
colDiv.style.transition = 'border-color 0.2s, box-shadow 0.2s';
|
||||
colDiv.draggable = true;
|
||||
|
||||
// HTML5 Drag & Drop handlers
|
||||
colDiv.addEventListener('dragstart', (e) => {
|
||||
e.dataTransfer.setData('text/plain', i);
|
||||
colDiv.style.opacity = '0.5';
|
||||
});
|
||||
colDiv.addEventListener('dragend', () => {
|
||||
colDiv.style.opacity = '1';
|
||||
});
|
||||
colDiv.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
colDiv.style.borderColor = '#007bff';
|
||||
colDiv.style.boxShadow = '0 0 0 2px rgba(0, 123, 255, 0.25)';
|
||||
});
|
||||
colDiv.addEventListener('dragleave', () => {
|
||||
colDiv.style.borderColor = '';
|
||||
colDiv.style.boxShadow = '0 1px 3px rgba(0,0,0,0.05)';
|
||||
});
|
||||
colDiv.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
colDiv.style.borderColor = '';
|
||||
colDiv.style.boxShadow = '0 1px 3px rgba(0,0,0,0.05)';
|
||||
const fromIdx = parseInt(e.dataTransfer.getData('text/plain'));
|
||||
if (fromIdx && fromIdx !== i) {
|
||||
this._swapItems(fromIdx, i);
|
||||
}
|
||||
});
|
||||
|
||||
const headerRow = document.createElement('div');
|
||||
headerRow.className = 'd-flex justify-content-between align-items-center mb-2 pb-1 border-bottom';
|
||||
|
||||
// Left Header: 6-dot drag icon, Col Label, Move Left/Right, Add Col (+), Delete (x)
|
||||
const labelWrapper = document.createElement('div');
|
||||
labelWrapper.className = 'd-flex align-items-center';
|
||||
|
||||
// 6-dot Drag Handle Icon
|
||||
const dragIcon = document.createElement('span');
|
||||
dragIcon.className = 'mr-2 text-muted sis-drag-handle-dots';
|
||||
dragIcon.style.cursor = 'grab';
|
||||
dragIcon.style.userSelect = 'none';
|
||||
dragIcon.style.display = 'inline-flex';
|
||||
dragIcon.style.alignItems = 'center';
|
||||
dragIcon.title = 'Kéo để sắp xếp, hoặc Click để mở menu chức năng đầy đủ';
|
||||
dragIcon.innerHTML = `<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" style="opacity: 0.65;"><circle cx="9" cy="5" r="2.2"/><circle cx="9" cy="12" r="2.2"/><circle cx="9" cy="19" r="2.2"/><circle cx="15" cy="5" r="2.2"/><circle cx="15" cy="12" r="2.2"/><circle cx="15" cy="19" r="2.2"/></svg>`;
|
||||
dragIcon.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this._showItem6DotsMenu(i, dragIcon);
|
||||
});
|
||||
|
||||
const label = document.createElement('label');
|
||||
label.className = 'small font-weight-bold text-dark mb-0';
|
||||
label.className = 'small font-weight-bold text-dark mb-0 mr-2';
|
||||
label.innerText = `Col ${i}`;
|
||||
headerRow.appendChild(label);
|
||||
|
||||
// Controls: Move Left (◀), Move Right (▶), + Col, Delete (✕)
|
||||
const moveLeftBtn = document.createElement('button');
|
||||
moveLeftBtn.type = 'button';
|
||||
moveLeftBtn.className = 'btn btn-xs btn-light border text-muted mr-1 px-1 py-0';
|
||||
moveLeftBtn.style.fontSize = '10px';
|
||||
moveLeftBtn.title = 'Move Left / Sang trái';
|
||||
moveLeftBtn.innerText = '◀';
|
||||
if (i === 1 || this.readOnly) moveLeftBtn.disabled = true;
|
||||
moveLeftBtn.addEventListener('click', () => this._swapItems(i, i - 1));
|
||||
|
||||
const moveRightBtn = document.createElement('button');
|
||||
moveRightBtn.type = 'button';
|
||||
moveRightBtn.className = 'btn btn-xs btn-light border text-muted mr-1 px-1 py-0';
|
||||
moveRightBtn.style.fontSize = '10px';
|
||||
moveRightBtn.title = 'Move Right / Sang phải';
|
||||
moveRightBtn.innerText = '▶';
|
||||
if (i === count || this.readOnly) moveRightBtn.disabled = true;
|
||||
moveRightBtn.addEventListener('click', () => this._swapItems(i, i + 1));
|
||||
|
||||
const addItemBtn = document.createElement('button');
|
||||
addItemBtn.type = 'button';
|
||||
addItemBtn.className = 'btn btn-xs btn-outline-success mr-1 px-1 py-0 font-weight-bold';
|
||||
addItemBtn.style.fontSize = '10px';
|
||||
addItemBtn.title = 'Insert new column after this / Thêm cột mới';
|
||||
addItemBtn.innerText = '+ Col';
|
||||
if (count >= 12 || this.readOnly) addItemBtn.disabled = true;
|
||||
addItemBtn.addEventListener('click', () => this._addItem(i));
|
||||
|
||||
const deleteBtn = document.createElement('button');
|
||||
deleteBtn.type = 'button';
|
||||
deleteBtn.className = 'btn btn-xs btn-outline-danger px-1 py-0 font-weight-bold';
|
||||
deleteBtn.style.fontSize = '10px';
|
||||
deleteBtn.title = 'Delete column / Xóa cột';
|
||||
deleteBtn.innerText = '✕';
|
||||
if (count <= 1 || this.readOnly) deleteBtn.disabled = true;
|
||||
deleteBtn.addEventListener('click', () => this._deleteItem(i));
|
||||
|
||||
labelWrapper.appendChild(dragIcon);
|
||||
labelWrapper.appendChild(label);
|
||||
labelWrapper.appendChild(moveLeftBtn);
|
||||
labelWrapper.appendChild(moveRightBtn);
|
||||
labelWrapper.appendChild(addItemBtn);
|
||||
labelWrapper.appendChild(deleteBtn);
|
||||
|
||||
headerRow.appendChild(labelWrapper);
|
||||
|
||||
const controlsWrapper = document.createElement('div');
|
||||
controlsWrapper.className = 'd-flex align-items-center gap-1';
|
||||
controlsWrapper.className = 'd-flex align-items-center gap-1';
|
||||
|
||||
// Width Selector
|
||||
const widthSelect = document.createElement('select');
|
||||
@@ -272,8 +653,12 @@ class GridTool {
|
||||
|
||||
// Dynamically add other registered plugins from window.SISEditorPlugins
|
||||
if (window.SISEditorPlugins) {
|
||||
const seenPlugins = new Set();
|
||||
Object.keys(window.SISEditorPlugins).forEach(key => {
|
||||
// Allow nesting any registered plugins (no exclusions)
|
||||
const entry = window.SISEditorPlugins[key];
|
||||
const pluginObj = entry ? (entry.class || entry) : null;
|
||||
if (pluginObj && seenPlugins.has(pluginObj)) return;
|
||||
if (pluginObj) seenPlugins.add(pluginObj);
|
||||
if (!types.some(t => t.value === key)) {
|
||||
types.push({ value: key, label: `[Plugin] ${key}` });
|
||||
}
|
||||
@@ -330,7 +715,12 @@ class GridTool {
|
||||
{ value: 'accordion', label: 'Accordion' }
|
||||
];
|
||||
if (window.SISEditorPlugins) {
|
||||
const seenSubPlugins = new Set();
|
||||
Object.keys(window.SISEditorPlugins).forEach(key => {
|
||||
const entry = window.SISEditorPlugins[key];
|
||||
const pluginObj = entry ? (entry.class || entry) : null;
|
||||
if (pluginObj && seenSubPlugins.has(pluginObj)) return;
|
||||
if (pluginObj) seenSubPlugins.add(pluginObj);
|
||||
if (!subTypes.some(t => t.value === key)) {
|
||||
subTypes.push({ value: key, label: `[Plugin] ${key}` });
|
||||
}
|
||||
@@ -393,6 +783,18 @@ class GridTool {
|
||||
renderSubInputs();
|
||||
}
|
||||
|
||||
if (count < 12 && !this.readOnly) {
|
||||
const addCardCol = document.createElement('div');
|
||||
addCardCol.className = 'col-12 mt-1 text-center';
|
||||
const addCardBtn = document.createElement('button');
|
||||
addCardBtn.type = 'button';
|
||||
addCardBtn.className = 'btn btn-sm btn-outline-primary font-weight-bold px-3 py-1 shadow-sm';
|
||||
addCardBtn.innerHTML = '<i class="fas fa-plus mr-1"></i> + Thêm Cột Mới';
|
||||
addCardBtn.addEventListener('click', () => this._addItem(count));
|
||||
addCardCol.appendChild(addCardBtn);
|
||||
row.appendChild(addCardCol);
|
||||
}
|
||||
|
||||
this.inputsContainer.appendChild(row);
|
||||
}
|
||||
|
||||
@@ -506,8 +908,16 @@ class GridTool {
|
||||
if (window.SISMediaPicker) {
|
||||
SISMediaPicker.open((url, mediaObj, config) => {
|
||||
if (url) {
|
||||
const fileObj = {
|
||||
url: url,
|
||||
style: (config && config.style) || '',
|
||||
cssClass: (config && config.cssClass) || '',
|
||||
alt: (config && config.alt) || '',
|
||||
customAttributes: (config && config.customAttributes) || '',
|
||||
aspectRatio: (config && config.aspectRatio) || ''
|
||||
};
|
||||
this.data[`col${i}`] = {
|
||||
file: { url: url },
|
||||
file: fileObj,
|
||||
caption: (config && config.alt) || '',
|
||||
withBorder: false,
|
||||
withBackground: false,
|
||||
@@ -703,23 +1113,135 @@ class GridTool {
|
||||
});
|
||||
|
||||
} else if (type === 'youtube') {
|
||||
const label = document.createElement('label');
|
||||
label.className = 'font-weight-bold text-danger small mb-1';
|
||||
label.innerHTML = '<i class="fab fa-youtube"></i> Đường dẫn YouTube Video (URL / ID)';
|
||||
|
||||
const input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
input.className = 'form-control form-control-sm mb-2';
|
||||
input.placeholder = 'Paste YouTube video URL...';
|
||||
input.placeholder = 'Paste YouTube video URL (e.g. https://www.youtube.com/watch?v=sDE4ZZdNOOY)...';
|
||||
input.value = this.data[`ytUrl${i}`] || '';
|
||||
if (this.readOnly) input.disabled = true;
|
||||
|
||||
const preview = document.createElement('div');
|
||||
preview.className = 'small text-muted p-2 bg-light border rounded';
|
||||
preview.innerText = 'Format: https://www.youtube.com/watch?v=XXXXXX';
|
||||
// Custom Thumbnail Group
|
||||
const thumbLabel = document.createElement('label');
|
||||
thumbLabel.className = 'font-weight-bold text-secondary small mb-1';
|
||||
thumbLabel.innerHTML = '<i class="fas fa-image"></i> Ảnh bìa Custom Thumbnail (Tùy chọn - Đè lên bìa YouTube)';
|
||||
|
||||
const thumbGroup = document.createElement('div');
|
||||
thumbGroup.className = 'input-group input-group-sm mb-2';
|
||||
|
||||
const thumbInput = document.createElement('input');
|
||||
thumbInput.type = 'text';
|
||||
thumbInput.className = 'form-control';
|
||||
thumbInput.placeholder = 'URL ảnh bìa hoặc chọn từ Thư viện Media...';
|
||||
thumbInput.value = this.data[`ytThumbUrl${i}`] || this.data[`imgUrl${i}`] || '';
|
||||
if (this.readOnly) thumbInput.disabled = true;
|
||||
|
||||
const thumbAppend = document.createElement('div');
|
||||
thumbAppend.className = 'input-group-append';
|
||||
|
||||
const mediaBtn = document.createElement('button');
|
||||
mediaBtn.type = 'button';
|
||||
mediaBtn.className = 'btn btn-outline-primary font-weight-bold';
|
||||
mediaBtn.innerHTML = '<i class="fas fa-images mr-1"></i> Thư viện Media';
|
||||
if (this.readOnly) mediaBtn.disabled = true;
|
||||
|
||||
mediaBtn.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
if (window.SISMediaPicker) {
|
||||
SISMediaPicker.open((selectedUrl) => {
|
||||
thumbInput.value = selectedUrl;
|
||||
this.data[`ytThumbUrl${i}`] = selectedUrl;
|
||||
this.data[`imgUrl${i}`] = selectedUrl;
|
||||
updatePreview();
|
||||
});
|
||||
} else {
|
||||
alert('Thư viện Media (SISMediaPicker) chưa được tải.');
|
||||
}
|
||||
});
|
||||
|
||||
thumbAppend.appendChild(mediaBtn);
|
||||
thumbGroup.appendChild(thumbInput);
|
||||
thumbGroup.appendChild(thumbAppend);
|
||||
|
||||
// Stretch / Full-width Switch
|
||||
const stretchWrapper = document.createElement('div');
|
||||
stretchWrapper.className = 'custom-control custom-switch mb-2';
|
||||
|
||||
const stretchCheck = document.createElement('input');
|
||||
stretchCheck.type = 'checkbox';
|
||||
stretchCheck.className = 'custom-control-input';
|
||||
const switchId = 'yt_item_stretch_' + i + '_' + Math.random().toString(36).substring(7);
|
||||
stretchCheck.id = switchId;
|
||||
stretchCheck.checked = !!(this.data[`ytStretched${i}`]);
|
||||
if (this.readOnly) stretchCheck.disabled = true;
|
||||
|
||||
const stretchLabel = document.createElement('label');
|
||||
stretchLabel.className = 'custom-control-label small font-weight-bold text-dark';
|
||||
stretchLabel.htmlFor = switchId;
|
||||
stretchLabel.style.cursor = 'pointer';
|
||||
stretchLabel.innerHTML = '<i class="fas fa-arrows-alt-h text-primary"></i> Ép full chiều rộng (Force Full Width)';
|
||||
|
||||
stretchCheck.addEventListener('change', () => {
|
||||
this.data[`ytStretched${i}`] = stretchCheck.checked;
|
||||
});
|
||||
|
||||
stretchWrapper.appendChild(stretchCheck);
|
||||
stretchWrapper.appendChild(stretchLabel);
|
||||
|
||||
// Thumbnail Preview Box
|
||||
const previewBox = document.createElement('div');
|
||||
previewBox.className = 'yt-preview-card p-2 bg-light border rounded text-center mb-2';
|
||||
previewBox.style.minHeight = '60px';
|
||||
previewBox.style.borderRadius = '6px';
|
||||
previewBox.style.background = '#111 center/cover no-repeat';
|
||||
previewBox.style.position = 'relative';
|
||||
|
||||
const updatePreview = () => {
|
||||
const url = input.value.trim();
|
||||
const customThumb = thumbInput.value.trim();
|
||||
let videoId = '';
|
||||
var regExp = /^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|\&v=)([^#\&\?]*).*/;
|
||||
var match = url.match(regExp);
|
||||
if (match && match[2].length === 11) videoId = match[2];
|
||||
else if (url.length === 11) videoId = url;
|
||||
|
||||
const thumbUrl = customThumb || (videoId ? 'https://img.youtube.com/vi/' + videoId + '/hqdefault.jpg' : '');
|
||||
if (thumbUrl) {
|
||||
previewBox.style.backgroundImage = 'url("' + thumbUrl + '")';
|
||||
previewBox.style.paddingBottom = '56.25%';
|
||||
previewBox.style.height = '0';
|
||||
previewBox.style.display = 'block';
|
||||
} else {
|
||||
previewBox.style.backgroundImage = 'none';
|
||||
previewBox.style.paddingBottom = '0';
|
||||
previewBox.style.height = 'auto';
|
||||
previewBox.innerText = 'Format: https://www.youtube.com/watch?v=XXXXXX';
|
||||
}
|
||||
};
|
||||
|
||||
input.addEventListener('input', (e) => {
|
||||
this.data[`ytUrl${i}`] = e.target.value.trim();
|
||||
updatePreview();
|
||||
});
|
||||
|
||||
thumbInput.addEventListener('input', (e) => {
|
||||
const val = e.target.value.trim();
|
||||
this.data[`ytThumbUrl${i}`] = val;
|
||||
this.data[`imgUrl${i}`] = val;
|
||||
updatePreview();
|
||||
});
|
||||
|
||||
updatePreview();
|
||||
|
||||
typeContainer.appendChild(label);
|
||||
typeContainer.appendChild(input);
|
||||
typeContainer.appendChild(preview);
|
||||
typeContainer.appendChild(thumbLabel);
|
||||
typeContainer.appendChild(thumbGroup);
|
||||
typeContainer.appendChild(stretchWrapper);
|
||||
typeContainer.appendChild(previewBox);
|
||||
|
||||
} else if (type === 'accordion') {
|
||||
const titleInput = document.createElement('input');
|
||||
@@ -820,7 +1342,14 @@ class GridTool {
|
||||
}
|
||||
|
||||
savedData[`imgUrl${i}`] = this.data[`imgUrl${i}`] || '';
|
||||
savedData[`imgId${i}`] = this.data[`imgId${i}`] || (this.data[`col${i}`] && this.data[`col${i}`].file ? (this.data[`col${i}`].file.id || this.data[`col${i}`].file.elementId) : '');
|
||||
savedData[`imgStyle${i}`] = this.data[`imgStyle${i}`] || (this.data[`col${i}`] && this.data[`col${i}`].file ? this.data[`col${i}`].file.style : '');
|
||||
savedData[`imgClass${i}`] = this.data[`imgClass${i}`] || (this.data[`col${i}`] && this.data[`col${i}`].file ? this.data[`col${i}`].file.cssClass : '');
|
||||
savedData[`imgAlt${i}`] = this.data[`imgAlt${i}`] || (this.data[`col${i}`] && this.data[`col${i}`].file ? this.data[`col${i}`].file.alt : '');
|
||||
savedData[`imgAttrs${i}`] = this.data[`imgAttrs${i}`] || (this.data[`col${i}`] && this.data[`col${i}`].file ? this.data[`col${i}`].file.customAttributes : '');
|
||||
savedData[`ytUrl${i}`] = this.data[`ytUrl${i}`] || '';
|
||||
savedData[`ytThumbUrl${i}`] = this.data[`ytThumbUrl${i}`] || '';
|
||||
savedData[`ytStretched${i}`] = !!this.data[`ytStretched${i}`];
|
||||
savedData[`accTitle${i}`] = this.data[`accTitle${i}`] || '';
|
||||
savedData[`accContent${i}`] = this.data[`accContent${i}`] || '';
|
||||
savedData[`class${i}`] = this.data[`class${i}`] || '';
|
||||
@@ -847,6 +1376,8 @@ class GridTool {
|
||||
|
||||
savedData[`imgUrlsub_${i}`] = this.data[`imgUrlsub_${i}`] || '';
|
||||
savedData[`ytUrlsub_${i}`] = this.data[`ytUrlsub_${i}`] || '';
|
||||
savedData[`ytThumbUrlsub_${i}`] = this.data[`ytThumbUrlsub_${i}`] || '';
|
||||
savedData[`ytStretchedsub_${i}`] = !!this.data[`ytStretchedsub_${i}`];
|
||||
savedData[`accTitlesub_${i}`] = this.data[`accTitlesub_${i}`] || '';
|
||||
savedData[`accContentsub_${i}`] = this.data[`accContentsub_${i}`] || '';
|
||||
savedData[`classsub_${i}`] = this.data[`classsub_${i}`] || '';
|
||||
|
||||
@@ -89,6 +89,12 @@ class SISHoverCardTool {
|
||||
if (this.data.styleVariant === 'v2') optV2.selected = true;
|
||||
variantSelect.appendChild(optV2);
|
||||
|
||||
const optV3 = document.createElement('option');
|
||||
optV3.value = 'v3';
|
||||
optV3.textContent = 'Hover 3 (Horizontal Layout)';
|
||||
if (this.data.styleVariant === 'v3') optV3.selected = true;
|
||||
variantSelect.appendChild(optV3);
|
||||
|
||||
if (this.readOnly) variantSelect.disabled = true;
|
||||
variantSelect.addEventListener('change', (e) => {
|
||||
this.data.styleVariant = e.target.value;
|
||||
|
||||
@@ -75,6 +75,7 @@ class SISPostsTool {
|
||||
const styleOpts = [
|
||||
{ val: 'grid', lbl: 'Grid Cards (Default)' },
|
||||
{ val: 'bento_layout_01', lbl: 'Bento Layout 01' },
|
||||
{ val: 'bento_layout_02', lbl: 'Bento Layout 02' },
|
||||
{ val: 'card_grid_layout', lbl: 'Card Grid Layout' },
|
||||
{ val: 'list', lbl: 'Horizontal List' },
|
||||
{ val: 'cards', lbl: 'Featured Large Cards' },
|
||||
|
||||
@@ -29,3 +29,9 @@ class TabEndTool {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
// Register globally
|
||||
window.TabEndTool = TabEndTool;
|
||||
window.SISEditorPlugins = window.SISEditorPlugins || {};
|
||||
window.SISEditorPlugins['tabEnd'] = { class: TabEndTool };
|
||||
window.SISEditorPlugins['tab-end'] = window.SISEditorPlugins['tabEnd'];
|
||||
|
||||
@@ -0,0 +1,439 @@
|
||||
/**
|
||||
* SISTabPluginTool — Dedicated Multi-Tab Block Tool for Editor.js (SIS Vietnam)
|
||||
* Allows content editors to add responsive, tabbed content panels inside pages.
|
||||
* Fully supports Custom CSS Class, HTML ID, Inline Style, and Custom Attributes for both Wrapper & individual Tabs.
|
||||
*/
|
||||
class SISTabPluginTool {
|
||||
static get toolbox() {
|
||||
return {
|
||||
title: 'Tab Panel',
|
||||
icon: '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 6h16M4 6v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V6"></path><path d="M4 10h16"></path><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"></path></svg>'
|
||||
};
|
||||
}
|
||||
|
||||
constructor({ data, api, readOnly }) {
|
||||
this.api = api;
|
||||
this.readOnly = readOnly;
|
||||
this.instanceId = 'tp_' + Math.random().toString(36).substring(7);
|
||||
this.activeInstances = {};
|
||||
|
||||
this.data = {
|
||||
globalId: (data && data.globalId) || '',
|
||||
globalClass: (data && data.globalClass) || '',
|
||||
globalStyle: (data && data.globalStyle) || '',
|
||||
globalAttributes: (data && data.globalAttributes) || (data && data.attributes) || '',
|
||||
items: (data && data.items && Array.isArray(data.items)) ? data.items : []
|
||||
};
|
||||
|
||||
if (this.data.items.length === 0) {
|
||||
this.data.items.push(
|
||||
{ title: 'Tab 1', type: 'html', content: 'Nội dung Tab 1...', class: '', id: '', style: '', attributes: '' },
|
||||
{ title: 'Tab 2', type: 'html', content: 'Nội dung Tab 2...', class: '', id: '', style: '', attributes: '' }
|
||||
);
|
||||
}
|
||||
this.wrapper = undefined;
|
||||
}
|
||||
|
||||
render() {
|
||||
this.wrapper = document.createElement('div');
|
||||
this.wrapper.className = 'p-3 bg-light border rounded mb-3 ce-tab-plugin-tool-wrapper';
|
||||
this.wrapper.style.fontFamily = 'inherit';
|
||||
|
||||
const headerDiv = document.createElement('div');
|
||||
headerDiv.className = 'd-flex justify-content-between align-items-center mb-3 pb-2 border-bottom';
|
||||
headerDiv.innerHTML = '<div class="font-weight-bold text-primary small"><i class="fas fa-folder mr-1"></i> Multi-Tab Panel Settings</div>';
|
||||
|
||||
if (!this.readOnly) {
|
||||
const addTabTopBtn = document.createElement('button');
|
||||
addTabTopBtn.type = 'button';
|
||||
addTabTopBtn.className = 'btn btn-xs btn-success shadow-sm';
|
||||
addTabTopBtn.style.fontSize = '12px';
|
||||
addTabTopBtn.innerHTML = '<i class="fas fa-plus mr-1"></i> Thêm Tab Mới';
|
||||
addTabTopBtn.addEventListener('click', () => this._addTab());
|
||||
headerDiv.appendChild(addTabTopBtn);
|
||||
}
|
||||
this.wrapper.appendChild(headerDiv);
|
||||
|
||||
// Global Class, ID, Style & Attributes row
|
||||
const globalRow = document.createElement('div');
|
||||
globalRow.className = 'form-row mb-3 pb-2 border-bottom';
|
||||
|
||||
// 1. CSS Class
|
||||
const classDiv = document.createElement('div');
|
||||
classDiv.className = 'col-md-3 mb-2';
|
||||
classDiv.innerHTML = '<label class="small font-weight-bold text-secondary mb-1">CSS Class (Wrapper)</label>';
|
||||
const classInput = document.createElement('input');
|
||||
classInput.type = 'text';
|
||||
classInput.className = 'form-control form-control-sm';
|
||||
classInput.placeholder = 'e.g. my-tabs-wrapper';
|
||||
classInput.value = this.data.globalClass || '';
|
||||
if (this.readOnly) classInput.disabled = true;
|
||||
classInput.addEventListener('input', (e) => this.data.globalClass = e.target.value.trim());
|
||||
classDiv.appendChild(classInput);
|
||||
globalRow.appendChild(classDiv);
|
||||
|
||||
// 2. HTML ID
|
||||
const idDiv = document.createElement('div');
|
||||
idDiv.className = 'col-md-3 mb-2';
|
||||
idDiv.innerHTML = '<label class="small font-weight-bold text-secondary mb-1">HTML ID</label>';
|
||||
const idInput = document.createElement('input');
|
||||
idInput.type = 'text';
|
||||
idInput.className = 'form-control form-control-sm';
|
||||
idInput.placeholder = 'e.g. section-tabs-1';
|
||||
idInput.value = this.data.globalId || '';
|
||||
if (this.readOnly) idInput.disabled = true;
|
||||
idInput.addEventListener('input', (e) => this.data.globalId = e.target.value.trim());
|
||||
idDiv.appendChild(idInput);
|
||||
globalRow.appendChild(idDiv);
|
||||
|
||||
// 3. Inline Style
|
||||
const styleDiv = document.createElement('div');
|
||||
styleDiv.className = 'col-md-3 mb-2';
|
||||
styleDiv.innerHTML = '<label class="small font-weight-bold text-secondary mb-1">Inline Style</label>';
|
||||
const styleInput = document.createElement('input');
|
||||
styleInput.type = 'text';
|
||||
styleInput.className = 'form-control form-control-sm';
|
||||
styleInput.placeholder = 'e.g. background:#fff; padding:20px;';
|
||||
styleInput.value = this.data.globalStyle || '';
|
||||
if (this.readOnly) styleInput.disabled = true;
|
||||
styleInput.addEventListener('input', (e) => this.data.globalStyle = e.target.value.trim());
|
||||
styleDiv.appendChild(styleInput);
|
||||
globalRow.appendChild(styleDiv);
|
||||
|
||||
// 4. Custom Attributes
|
||||
const attrDiv = document.createElement('div');
|
||||
attrDiv.className = 'col-md-3 mb-2';
|
||||
attrDiv.innerHTML = '<label class="small font-weight-bold text-secondary mb-1">Custom Attributes</label>';
|
||||
const attrInput = document.createElement('input');
|
||||
attrInput.type = 'text';
|
||||
attrInput.className = 'form-control form-control-sm';
|
||||
attrInput.placeholder = 'e.g. data-aos="fade-up"';
|
||||
attrInput.value = this.data.globalAttributes || '';
|
||||
if (this.readOnly) attrInput.disabled = true;
|
||||
attrInput.addEventListener('input', (e) => this.data.globalAttributes = e.target.value.trim());
|
||||
attrDiv.appendChild(attrInput);
|
||||
globalRow.appendChild(attrDiv);
|
||||
|
||||
this.wrapper.appendChild(globalRow);
|
||||
|
||||
// Container for Tab items
|
||||
this.tabsContainer = document.createElement('div');
|
||||
this.wrapper.appendChild(this.tabsContainer);
|
||||
|
||||
this._renderTabs();
|
||||
|
||||
// Bottom Add Tab Button
|
||||
if (!this.readOnly) {
|
||||
const bottomActionDiv = document.createElement('div');
|
||||
bottomActionDiv.className = 'text-center mt-3 pt-2 border-top';
|
||||
const addTabBottomBtn = document.createElement('button');
|
||||
addTabBottomBtn.type = 'button';
|
||||
addTabBottomBtn.className = 'btn btn-sm btn-outline-primary shadow-sm px-4';
|
||||
addTabBottomBtn.innerHTML = '<i class="fas fa-plus mr-1"></i> + Thêm Tab Mới';
|
||||
addTabBottomBtn.addEventListener('click', () => this._addTab());
|
||||
bottomActionDiv.appendChild(addTabBottomBtn);
|
||||
this.wrapper.appendChild(bottomActionDiv);
|
||||
}
|
||||
|
||||
return this.wrapper;
|
||||
}
|
||||
|
||||
_addTab() {
|
||||
const nextNum = this.data.items.length + 1;
|
||||
this.data.items.push({
|
||||
title: `Tab ${nextNum}`,
|
||||
type: 'html',
|
||||
content: '',
|
||||
class: '',
|
||||
id: '',
|
||||
style: '',
|
||||
attributes: ''
|
||||
});
|
||||
this._renderTabs();
|
||||
}
|
||||
|
||||
_deleteTab(index) {
|
||||
if (this.data.items.length <= 1) return;
|
||||
this.data.items.splice(index, 1);
|
||||
this._renderTabs();
|
||||
}
|
||||
|
||||
_moveTab(index, direction) {
|
||||
const newIndex = index + direction;
|
||||
if (newIndex < 0 || newIndex >= this.data.items.length) return;
|
||||
const temp = this.data.items[index];
|
||||
this.data.items[index] = this.data.items[newIndex];
|
||||
this.data.items[newIndex] = temp;
|
||||
this._renderTabs();
|
||||
}
|
||||
|
||||
_renderTabs() {
|
||||
this.tabsContainer.innerHTML = '';
|
||||
this.activeInstances = {};
|
||||
|
||||
this.data.items.forEach((item, index) => {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'mb-3 p-3 border rounded bg-white ce-tab-item-card shadow-sm position-relative';
|
||||
card.draggable = !this.readOnly;
|
||||
|
||||
// Drag and drop handlers
|
||||
card.addEventListener('dragstart', (e) => {
|
||||
e.dataTransfer.setData('text/plain', index);
|
||||
card.style.opacity = '0.5';
|
||||
});
|
||||
card.addEventListener('dragend', () => card.style.opacity = '1');
|
||||
card.addEventListener('dragover', (e) => e.preventDefault());
|
||||
card.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
const fromIdx = parseInt(e.dataTransfer.getData('text/plain'));
|
||||
if (!isNaN(fromIdx) && fromIdx !== index) {
|
||||
const movedItem = this.data.items.splice(fromIdx, 1)[0];
|
||||
this.data.items.splice(index, 0, movedItem);
|
||||
this._renderTabs();
|
||||
}
|
||||
});
|
||||
|
||||
// Card Header Row
|
||||
const headerRow = document.createElement('div');
|
||||
headerRow.className = 'd-flex justify-content-between align-items-center mb-3 pb-2 border-bottom';
|
||||
|
||||
const leftControls = document.createElement('div');
|
||||
leftControls.className = 'd-flex align-items-center gap-2';
|
||||
|
||||
const dragHandle = document.createElement('span');
|
||||
dragHandle.className = 'mr-2 text-muted';
|
||||
dragHandle.style.cursor = 'grab';
|
||||
dragHandle.innerHTML = '⋮⋮';
|
||||
leftControls.appendChild(dragHandle);
|
||||
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'badge badge-primary mr-2';
|
||||
badge.innerText = `Tab #${index + 1}`;
|
||||
leftControls.appendChild(badge);
|
||||
|
||||
// Title input
|
||||
const titleInput = document.createElement('input');
|
||||
titleInput.type = 'text';
|
||||
titleInput.className = 'form-control form-control-sm font-weight-bold';
|
||||
titleInput.style.width = '180px';
|
||||
titleInput.placeholder = 'Tên Tab (Ví dụ: Tổng quan)...';
|
||||
titleInput.value = item.title || '';
|
||||
if (this.readOnly) titleInput.disabled = true;
|
||||
titleInput.addEventListener('input', (e) => item.title = e.target.value);
|
||||
leftControls.appendChild(titleInput);
|
||||
|
||||
headerRow.appendChild(leftControls);
|
||||
|
||||
const rightControls = document.createElement('div');
|
||||
rightControls.className = 'd-flex align-items-center gap-1';
|
||||
|
||||
// Move left / right buttons
|
||||
const moveLeftBtn = document.createElement('button');
|
||||
moveLeftBtn.type = 'button';
|
||||
moveLeftBtn.className = 'btn btn-xs btn-outline-secondary mr-1';
|
||||
moveLeftBtn.innerText = '◀';
|
||||
if (index === 0 || this.readOnly) moveLeftBtn.disabled = true;
|
||||
moveLeftBtn.addEventListener('click', () => this._moveTab(index, -1));
|
||||
|
||||
const moveRightBtn = document.createElement('button');
|
||||
moveRightBtn.type = 'button';
|
||||
moveRightBtn.className = 'btn btn-xs btn-outline-secondary mr-2';
|
||||
moveRightBtn.innerText = '▶';
|
||||
if (index === this.data.items.length - 1 || this.readOnly) moveRightBtn.disabled = true;
|
||||
moveRightBtn.addEventListener('click', () => this._moveTab(index, 1));
|
||||
|
||||
const deleteBtn = document.createElement('button');
|
||||
deleteBtn.type = 'button';
|
||||
deleteBtn.className = 'btn btn-xs btn-danger';
|
||||
deleteBtn.innerText = '✕';
|
||||
if (this.data.items.length <= 1 || this.readOnly) deleteBtn.disabled = true;
|
||||
deleteBtn.addEventListener('click', () => this._deleteTab(index));
|
||||
|
||||
rightControls.appendChild(moveLeftBtn);
|
||||
rightControls.appendChild(moveRightBtn);
|
||||
rightControls.appendChild(deleteBtn);
|
||||
|
||||
headerRow.appendChild(rightControls);
|
||||
card.appendChild(headerRow);
|
||||
|
||||
// Type Selector
|
||||
const typeRow = document.createElement('div');
|
||||
typeRow.className = 'form-group mb-2';
|
||||
const typeLabel = document.createElement('label');
|
||||
typeLabel.className = 'small font-weight-bold text-secondary mb-1';
|
||||
typeLabel.innerText = 'Loại nội dung của Tab:';
|
||||
typeRow.appendChild(typeLabel);
|
||||
|
||||
const typeSelect = document.createElement('select');
|
||||
typeSelect.className = 'custom-select custom-select-sm';
|
||||
if (this.readOnly) typeSelect.disabled = true;
|
||||
|
||||
const types = [
|
||||
{ value: 'html', label: 'HTML / Text' },
|
||||
{ value: 'tinymce', label: 'TinyMCE Editor' },
|
||||
{ value: 'image', label: 'Hình ảnh (Image)' },
|
||||
{ value: 'youtube', label: 'Video YouTube' },
|
||||
{ value: 'accordion', label: 'Accordion (Xổ xuống)' }
|
||||
];
|
||||
|
||||
if (window.SISEditorPlugins) {
|
||||
const seenPlugins = new Set();
|
||||
Object.keys(window.SISEditorPlugins).forEach(key => {
|
||||
const entry = window.SISEditorPlugins[key];
|
||||
const pluginObj = entry ? (entry.class || entry) : null;
|
||||
if (pluginObj && seenPlugins.has(pluginObj)) return;
|
||||
if (pluginObj) seenPlugins.add(pluginObj);
|
||||
if (!types.some(t => t.value === key)) {
|
||||
types.push({ value: key, label: `[Plugin] ${key}` });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
types.forEach(t => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = t.value;
|
||||
opt.innerText = t.label;
|
||||
if ((item.type || 'html') === t.value) opt.selected = true;
|
||||
typeSelect.appendChild(opt);
|
||||
});
|
||||
|
||||
typeRow.appendChild(typeSelect);
|
||||
card.appendChild(typeRow);
|
||||
|
||||
// Advanced Item Settings (Class, ID, Style, Attributes)
|
||||
const itemAdvRow = document.createElement('div');
|
||||
itemAdvRow.className = 'form-row mb-2 p-2 bg-light rounded border';
|
||||
|
||||
const itemClassDiv = document.createElement('div');
|
||||
itemClassDiv.className = 'col-md-3 mb-1';
|
||||
const itemClassInput = document.createElement('input');
|
||||
itemClassInput.type = 'text';
|
||||
itemClassInput.className = 'form-control form-control-sm';
|
||||
itemClassInput.placeholder = 'Item Class...';
|
||||
itemClassInput.value = item.class || '';
|
||||
if (this.readOnly) itemClassInput.disabled = true;
|
||||
itemClassInput.addEventListener('input', (e) => item.class = e.target.value.trim());
|
||||
itemClassDiv.appendChild(itemClassInput);
|
||||
itemAdvRow.appendChild(itemClassDiv);
|
||||
|
||||
const itemIdDiv = document.createElement('div');
|
||||
itemIdDiv.className = 'col-md-3 mb-1';
|
||||
const itemIdInput = document.createElement('input');
|
||||
itemIdInput.type = 'text';
|
||||
itemIdInput.className = 'form-control form-control-sm';
|
||||
itemIdInput.placeholder = 'Item ID...';
|
||||
itemIdInput.value = item.id || '';
|
||||
if (this.readOnly) itemIdInput.disabled = true;
|
||||
itemIdInput.addEventListener('input', (e) => item.id = e.target.value.trim());
|
||||
itemIdDiv.appendChild(itemIdInput);
|
||||
itemAdvRow.appendChild(itemIdDiv);
|
||||
|
||||
const itemStyleDiv = document.createElement('div');
|
||||
itemStyleDiv.className = 'col-md-3 mb-1';
|
||||
const itemStyleInput = document.createElement('input');
|
||||
itemStyleInput.type = 'text';
|
||||
itemStyleInput.className = 'form-control form-control-sm';
|
||||
itemStyleInput.placeholder = 'Item Style...';
|
||||
itemStyleInput.value = item.style || '';
|
||||
if (this.readOnly) itemStyleInput.disabled = true;
|
||||
itemStyleInput.addEventListener('input', (e) => item.style = e.target.value.trim());
|
||||
itemStyleDiv.appendChild(itemStyleInput);
|
||||
itemAdvRow.appendChild(itemStyleDiv);
|
||||
|
||||
const itemAttrDiv = document.createElement('div');
|
||||
itemAttrDiv.className = 'col-md-3 mb-1';
|
||||
const itemAttrInput = document.createElement('input');
|
||||
itemAttrInput.type = 'text';
|
||||
itemAttrInput.className = 'form-control form-control-sm';
|
||||
itemAttrInput.placeholder = 'Item Custom Attrs...';
|
||||
itemAttrInput.value = item.attributes || item.customAttributes || '';
|
||||
if (this.readOnly) itemAttrInput.disabled = true;
|
||||
itemAttrInput.addEventListener('input', (e) => item.attributes = e.target.value.trim());
|
||||
itemAttrDiv.appendChild(itemAttrInput);
|
||||
itemAdvRow.appendChild(itemAttrDiv);
|
||||
|
||||
card.appendChild(itemAdvRow);
|
||||
|
||||
const contentContainer = document.createElement('div');
|
||||
contentContainer.className = 'tab-content-input-container mt-2';
|
||||
|
||||
typeSelect.addEventListener('change', (e) => {
|
||||
item.type = e.target.value;
|
||||
this._renderTabContentInput(index, item, contentContainer);
|
||||
});
|
||||
|
||||
card.appendChild(contentContainer);
|
||||
this._renderTabContentInput(index, item, contentContainer);
|
||||
|
||||
this.tabsContainer.appendChild(card);
|
||||
});
|
||||
}
|
||||
|
||||
_renderTabContentInput(index, item, container) {
|
||||
container.innerHTML = '';
|
||||
const type = item.type || 'html';
|
||||
|
||||
if (window.SISEditorPlugins && window.SISEditorPlugins[type]) {
|
||||
try {
|
||||
const PluginClass = window.SISEditorPlugins[type].class;
|
||||
const pluginInstance = new PluginClass({
|
||||
data: (typeof item.content === 'object' && item.content !== null) ? item.content : {},
|
||||
api: this.api,
|
||||
readOnly: this.readOnly
|
||||
});
|
||||
this.activeInstances[index] = pluginInstance;
|
||||
const pluginNode = pluginInstance.render();
|
||||
container.appendChild(pluginNode);
|
||||
return;
|
||||
} catch (err) {
|
||||
console.warn(`[SISTabPluginTool] Error rendering plugin '${type}' for tab ${index}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
// Standard HTML Textarea
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.className = 'form-control form-control-sm';
|
||||
textarea.rows = 4;
|
||||
textarea.placeholder = 'Nhập nội dung HTML hoặc văn bản cho Tab này...';
|
||||
textarea.value = (typeof item.content === 'string') ? item.content : JSON.stringify(item.content || '');
|
||||
if (this.readOnly) textarea.disabled = true;
|
||||
textarea.addEventListener('input', (e) => item.content = e.target.value);
|
||||
container.appendChild(textarea);
|
||||
}
|
||||
|
||||
save() {
|
||||
// Collect current plugin values if available
|
||||
this.data.items.forEach((item, index) => {
|
||||
if (this.activeInstances[index] && typeof this.activeInstances[index].save === 'function') {
|
||||
try {
|
||||
item.content = this.activeInstances[index].save();
|
||||
} catch (err) {
|
||||
console.warn(`[SISTabPluginTool] Save error for tab ${index}:`, err);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
globalId: this.data.globalId || '',
|
||||
globalClass: this.data.globalClass || '',
|
||||
globalStyle: this.data.globalStyle || '',
|
||||
globalAttributes: this.data.globalAttributes || '',
|
||||
items: this.data.items
|
||||
};
|
||||
}
|
||||
|
||||
destroy() {
|
||||
Object.keys(this.activeInstances).forEach(key => {
|
||||
if (this.activeInstances[key] && typeof this.activeInstances[key].destroy === 'function') {
|
||||
try { this.activeInstances[key].destroy(); } catch (e) {}
|
||||
}
|
||||
});
|
||||
this.activeInstances = {};
|
||||
}
|
||||
}
|
||||
|
||||
// Register globally
|
||||
window.SISTabPluginTool = SISTabPluginTool;
|
||||
window.SISEditorPlugins = window.SISEditorPlugins || {};
|
||||
window.SISEditorPlugins['tabPlugin'] = { class: SISTabPluginTool };
|
||||
window.SISEditorPlugins['tab-plugin'] = window.SISEditorPlugins['tabPlugin'];
|
||||
@@ -63,3 +63,9 @@ class TabSplitTool {
|
||||
return {}; // We only care about the block type, no inner data needed
|
||||
}
|
||||
}
|
||||
|
||||
// Register globally
|
||||
window.TabSplitTool = TabSplitTool;
|
||||
window.SISEditorPlugins = window.SISEditorPlugins || {};
|
||||
window.SISEditorPlugins['tabSplit'] = { class: TabSplitTool };
|
||||
window.SISEditorPlugins['tab-split'] = window.SISEditorPlugins['tabSplit'];
|
||||
|
||||
@@ -42,9 +42,15 @@ class TabStartTool {
|
||||
}
|
||||
|
||||
save(blockContent) {
|
||||
const input = blockContent.querySelector('input');
|
||||
const input = blockContent ? blockContent.querySelector('input') : null;
|
||||
return {
|
||||
title: input ? input.value : this.data.title
|
||||
title: input ? input.value : (this.data.title || '')
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Register globally
|
||||
window.TabStartTool = TabStartTool;
|
||||
window.SISEditorPlugins = window.SISEditorPlugins || {};
|
||||
window.SISEditorPlugins['tabStart'] = { class: TabStartTool };
|
||||
window.SISEditorPlugins['tab-start'] = window.SISEditorPlugins['tabStart'];
|
||||
|
||||
@@ -24,6 +24,108 @@ class TextStylingTune {
|
||||
wrapper.className = 'cdx-settings-text-styling p-2 border-top';
|
||||
wrapper.style.fontFamily = 'inherit';
|
||||
|
||||
// === Copy & Paste Block Section ===
|
||||
const copySection = document.createElement('div');
|
||||
copySection.className = 'sis-block-copy-paste-tune mb-2 pb-2 border-bottom';
|
||||
|
||||
const copyBtn = document.createElement('button');
|
||||
copyBtn.type = 'button';
|
||||
copyBtn.className = 'btn btn-sm btn-outline-primary btn-block text-left mb-1';
|
||||
copyBtn.style.fontSize = '12px';
|
||||
copyBtn.style.fontWeight = '600';
|
||||
copyBtn.style.padding = '4px 8px';
|
||||
copyBtn.innerHTML = '<i class="fas fa-copy mr-1"></i> Sao chép khối (Copy Block)';
|
||||
copyBtn.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
let blockData = null;
|
||||
if (this.block && typeof this.block.save === 'function') {
|
||||
blockData = await this.block.save();
|
||||
} else if (this.api && this.api.saver) {
|
||||
const blockIdx = this.api.blocks.getBlockIndex(this.block.id);
|
||||
const savedOutput = await this.api.saver.save();
|
||||
if (savedOutput && savedOutput.blocks && savedOutput.blocks[blockIdx]) {
|
||||
blockData = savedOutput.blocks[blockIdx];
|
||||
}
|
||||
}
|
||||
|
||||
const blockType = (blockData && (blockData.tool || blockData.type)) || (this.block ? (this.block.name || this.block.tool) : 'paragraph');
|
||||
const blockContent = blockData ? (blockData.data || {}) : {};
|
||||
|
||||
const blockPayload = {
|
||||
type: blockType,
|
||||
data: blockContent,
|
||||
tunes: (blockData && blockData.tunes) || this.data || {}
|
||||
};
|
||||
const itemPayload = {
|
||||
type: blockType,
|
||||
col: typeof blockContent === 'string' ? blockContent : JSON.stringify(blockContent),
|
||||
subType: 'none',
|
||||
class: '',
|
||||
id: '',
|
||||
attr: '',
|
||||
width: 0
|
||||
};
|
||||
|
||||
const jsonStr = JSON.stringify(blockPayload);
|
||||
localStorage.setItem('sis_copied_editor_block', jsonStr);
|
||||
localStorage.setItem('sis_copied_item_data', JSON.stringify(itemPayload));
|
||||
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard.writeText(jsonStr).catch(() => {});
|
||||
}
|
||||
copyBtn.className = 'btn btn-sm btn-success btn-block text-left mb-1';
|
||||
copyBtn.innerHTML = '<i class="fas fa-check mr-1"></i> Đã sao chép khối!';
|
||||
setTimeout(() => {
|
||||
copyBtn.className = 'btn btn-sm btn-outline-primary btn-block text-left mb-1';
|
||||
copyBtn.innerHTML = '<i class="fas fa-copy mr-1"></i> Sao chép khối (Copy Block)';
|
||||
}, 2000);
|
||||
} catch (err) {
|
||||
console.error('[Copy Block Error]', err);
|
||||
}
|
||||
});
|
||||
copySection.appendChild(copyBtn);
|
||||
|
||||
const copiedRaw = localStorage.getItem('sis_copied_editor_block');
|
||||
if (copiedRaw) {
|
||||
try {
|
||||
const parsedBlock = JSON.parse(copiedRaw);
|
||||
if (parsedBlock && parsedBlock.type) {
|
||||
const pasteBtn = document.createElement('button');
|
||||
pasteBtn.type = 'button';
|
||||
pasteBtn.className = 'btn btn-sm btn-outline-success btn-block text-left mb-0';
|
||||
pasteBtn.style.fontSize = '12px';
|
||||
pasteBtn.style.fontWeight = '600';
|
||||
pasteBtn.style.padding = '4px 8px';
|
||||
pasteBtn.innerHTML = '<i class="fas fa-paste mr-1"></i> Dán khối đã chép bên dưới';
|
||||
pasteBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
if (this.api && this.api.blocks) {
|
||||
const curIdx = this.api.blocks.getBlockIndex(this.block.id);
|
||||
this.api.blocks.insert(
|
||||
parsedBlock.type,
|
||||
parsedBlock.data || {},
|
||||
{},
|
||||
curIdx + 1,
|
||||
true,
|
||||
false,
|
||||
parsedBlock.tunes || {}
|
||||
);
|
||||
pasteBtn.className = 'btn btn-sm btn-success btn-block text-left mb-0';
|
||||
pasteBtn.innerHTML = '<i class="fas fa-check mr-1"></i> Đã dán khối thành công!';
|
||||
setTimeout(() => {
|
||||
pasteBtn.className = 'btn btn-sm btn-outline-success btn-block text-left mb-0';
|
||||
pasteBtn.innerHTML = '<i class="fas fa-paste mr-1"></i> Dán khối đã chép bên dưới';
|
||||
}, 2000);
|
||||
}
|
||||
});
|
||||
copySection.appendChild(pasteBtn);
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
wrapper.appendChild(copySection);
|
||||
|
||||
const title = document.createElement('div');
|
||||
title.className = 'small font-weight-bold text-primary mb-2';
|
||||
title.innerHTML = '<i class="fas fa-palette"></i> Style & Alignment';
|
||||
|
||||
@@ -21,13 +21,16 @@
|
||||
|
||||
render() {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'sis-tinymce-block-wrapper border rounded p-2 bg-white';
|
||||
wrapper.className = 'sis-tinymce-block-wrapper border rounded p-2 bg-white d-flex flex-column h-100 flex-fill';
|
||||
wrapper.style.minHeight = '280px';
|
||||
wrapper.style.height = '100%';
|
||||
wrapper.style.flex = '1 1 auto';
|
||||
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.id = this.editorId;
|
||||
textarea.value = this.data.html;
|
||||
textarea.style.width = '100%';
|
||||
textarea.style.height = '100%';
|
||||
textarea.style.minHeight = '250px';
|
||||
wrapper.appendChild(textarea);
|
||||
this.textareaElement = textarea;
|
||||
@@ -88,13 +91,52 @@
|
||||
|
||||
tinymce.init({
|
||||
target: textarea,
|
||||
height: 250,
|
||||
height: '100%',
|
||||
min_height: 250,
|
||||
menubar: false,
|
||||
readonly: !!self.readOnly,
|
||||
plugins: 'advlist autolink lists link image charmap preview anchor searchreplace visualblocks code fullscreen insertdatetime media table code help wordcount',
|
||||
toolbar: 'undo redo | blocks | bold italic forecolor backcolor | alignleft aligncenter alignright alignjustify | bullist numlist outdent indent | table link image | code removeformat',
|
||||
image_dimensions: true,
|
||||
object_resizing: 'img',
|
||||
content_style: 'body { font-family: Roboto, ui-sans-serif, sans-serif; font-size: 14px; color: #333; } img { max-width: 100%; height: auto; cursor: pointer; display: inline-block; }',
|
||||
paste_postprocess: function(plugin, args) {
|
||||
if (args && args.node) {
|
||||
args.node.querySelectorAll('img').forEach(function(img) {
|
||||
img.style.removeProperty('inline-size');
|
||||
img.style.removeProperty('block-size');
|
||||
if (img.hasAttribute('width') && img.getAttribute('width') !== '100%') {
|
||||
img.style.removeProperty('width');
|
||||
img.style.removeProperty('height');
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
setup: function(editor) {
|
||||
self.editorInstance = editor;
|
||||
editor.on('init', function() {
|
||||
const container = editor.getContainer();
|
||||
if (container) {
|
||||
container.style.flex = '1 1 auto';
|
||||
container.style.height = '100%';
|
||||
container.style.minHeight = '250px';
|
||||
container.style.display = 'flex';
|
||||
container.style.flexDirection = 'column';
|
||||
|
||||
const editArea = container.querySelector('.tox-edit-area');
|
||||
if (editArea) {
|
||||
editArea.style.flex = '1 1 auto';
|
||||
editArea.style.display = 'flex';
|
||||
editArea.style.flexDirection = 'column';
|
||||
}
|
||||
|
||||
const iframe = container.querySelector('iframe');
|
||||
if (iframe) {
|
||||
iframe.style.height = '100%';
|
||||
iframe.style.flex = '1 1 auto';
|
||||
}
|
||||
}
|
||||
});
|
||||
editor.on('change keyup undo redo', function() {
|
||||
self.data.html = editor.getContent();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
class SISYouTubeTool {
|
||||
static get toolbox() {
|
||||
return {
|
||||
title: 'YouTube Video',
|
||||
icon: '<svg width="20" height="20" viewBox="0 0 24 24" fill="red"><path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z"/></svg>'
|
||||
};
|
||||
}
|
||||
|
||||
constructor({ data, api, readOnly }) {
|
||||
this.api = api;
|
||||
this.readOnly = readOnly;
|
||||
this.data = {
|
||||
url: (data && data.url) ? data.url : '',
|
||||
thumbnailUrl: (data && data.thumbnailUrl) ? data.thumbnailUrl : '',
|
||||
caption: (data && data.caption) ? data.caption : '',
|
||||
fullWidth: !!(data && (data.fullWidth || data.stretched))
|
||||
};
|
||||
this.urlInput = null;
|
||||
this.thumbnailInput = null;
|
||||
this.captionInput = null;
|
||||
this.previewContainer = null;
|
||||
this.iframe = null;
|
||||
this.isIframeActive = false;
|
||||
}
|
||||
|
||||
extractVideoId(input) {
|
||||
if (!input) return '';
|
||||
var regExp = /^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|\&v=)([^#\&\?]*).*/;
|
||||
var match = input.match(regExp);
|
||||
if (match && match[2].length === 11) {
|
||||
return match[2];
|
||||
}
|
||||
if (input.length === 11) {
|
||||
return input;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
getEmbedUrl(videoId) {
|
||||
return videoId ? 'https://www.youtube.com/embed/' + videoId : '';
|
||||
}
|
||||
|
||||
getThumbnailUrl(videoId) {
|
||||
return videoId ? 'https://img.youtube.com/vi/' + videoId + '/hqdefault.jpg' : '';
|
||||
}
|
||||
|
||||
render() {
|
||||
const container = document.createElement('div');
|
||||
container.style.border = '1px solid #e3e6f0';
|
||||
container.style.borderRadius = '8px';
|
||||
container.style.padding = '14px';
|
||||
container.style.background = '#fff';
|
||||
container.style.marginBottom = '12px';
|
||||
|
||||
const label = document.createElement('label');
|
||||
label.className = 'font-weight-bold text-danger small mb-1';
|
||||
label.innerHTML = '<i class="fab fa-youtube"></i> YouTube Video Link / URL / ID';
|
||||
|
||||
this.urlInput = document.createElement('input');
|
||||
this.urlInput.type = 'text';
|
||||
this.urlInput.className = 'form-control mb-2';
|
||||
this.urlInput.placeholder = 'Paste YouTube URL (e.g. https://www.youtube.com/watch?v=sDE4ZZdNOOY)...';
|
||||
this.urlInput.value = this.data.url;
|
||||
|
||||
// Custom Thumbnail Field with Media Library button
|
||||
const thumbLabel = document.createElement('label');
|
||||
thumbLabel.className = 'font-weight-bold text-secondary small mb-1';
|
||||
thumbLabel.innerHTML = '<i class="fas fa-image"></i> Ảnh bìa Video (Custom Thumbnail Cover Image)';
|
||||
|
||||
const thumbGroup = document.createElement('div');
|
||||
thumbGroup.className = 'input-group input-group-sm mb-2';
|
||||
|
||||
this.thumbnailInput = document.createElement('input');
|
||||
this.thumbnailInput.type = 'text';
|
||||
this.thumbnailInput.className = 'form-control';
|
||||
this.thumbnailInput.placeholder = 'URL ảnh bìa hoặc chọn từ thư viện media...';
|
||||
this.thumbnailInput.value = this.data.thumbnailUrl;
|
||||
|
||||
const thumbAppend = document.createElement('div');
|
||||
thumbAppend.className = 'input-group-append';
|
||||
|
||||
const mediaBtn = document.createElement('button');
|
||||
mediaBtn.type = 'button';
|
||||
mediaBtn.className = 'btn btn-outline-primary font-weight-bold';
|
||||
mediaBtn.innerHTML = '<i class="fas fa-images mr-1"></i> Thư viện Media';
|
||||
mediaBtn.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
if (window.SISMediaPicker) {
|
||||
SISMediaPicker.open((selectedUrl) => {
|
||||
this.thumbnailInput.value = selectedUrl;
|
||||
this.data.thumbnailUrl = selectedUrl;
|
||||
updatePreview();
|
||||
});
|
||||
} else {
|
||||
alert('Thư viện Media (SISMediaPicker) chưa được tải.');
|
||||
}
|
||||
});
|
||||
|
||||
thumbAppend.appendChild(mediaBtn);
|
||||
thumbGroup.appendChild(this.thumbnailInput);
|
||||
thumbGroup.appendChild(thumbAppend);
|
||||
|
||||
// Thumbnail Preview Container
|
||||
this.previewContainer = document.createElement('div');
|
||||
this.previewContainer.className = 'yt-thumbnail-preview';
|
||||
this.previewContainer.style.position = 'relative';
|
||||
this.previewContainer.style.paddingBottom = '56.25%';
|
||||
this.previewContainer.style.height = '0';
|
||||
this.previewContainer.style.overflow = 'hidden';
|
||||
this.previewContainer.style.background = '#111 center/cover no-repeat';
|
||||
this.previewContainer.style.borderRadius = '6px';
|
||||
this.previewContainer.style.marginBottom = '8px';
|
||||
this.previewContainer.style.cursor = 'pointer';
|
||||
this.previewContainer.style.boxShadow = '0 2px 8px rgba(0,0,0,0.15)';
|
||||
|
||||
// Play Button Overlay
|
||||
const playBtn = document.createElement('div');
|
||||
playBtn.className = 'yt-play-button-overlay';
|
||||
playBtn.style.position = 'absolute';
|
||||
playBtn.style.top = '50%';
|
||||
playBtn.style.left = '50%';
|
||||
playBtn.style.transform = 'translate(-50%, -50%)';
|
||||
playBtn.style.transition = 'transform 0.2s ease';
|
||||
playBtn.innerHTML = '<svg width="68" height="48" viewBox="0 0 68 48"><path d="M66.52 7.74c-.78-2.93-2.49-5.41-5.42-6.19C55.79.13 34 0 34 0S12.21.13 6.9 1.55c-2.93.78-4.63 3.26-5.42 6.19C.06 13.05 0 24 0 24s.06 10.95 1.48 16.26c.78 2.93 2.49 5.41 5.42 6.19C12.21 47.87 34 48 34 48s21.79-.13 27.1-1.55c2.93-.78 4.64-3.26 5.42-6.19C67.94 34.95 68 24 68 24s-.06-10.95-1.48-16.26z" fill="#ff0000"/><path d="M45 24L27 14v20z" fill="#ffffff"/></svg>';
|
||||
|
||||
this.previewContainer.appendChild(playBtn);
|
||||
|
||||
// Update Thumbnail Image
|
||||
const updatePreview = () => {
|
||||
const customThumb = this.thumbnailInput.value.trim();
|
||||
const videoId = this.extractVideoId(this.urlInput.value);
|
||||
const thumbUrl = customThumb || this.getThumbnailUrl(videoId);
|
||||
if (thumbUrl) {
|
||||
this.previewContainer.style.backgroundImage = 'url("' + thumbUrl + '")';
|
||||
playBtn.style.display = 'block';
|
||||
} else {
|
||||
this.previewContainer.style.backgroundImage = 'none';
|
||||
this.previewContainer.style.backgroundColor = '#222';
|
||||
}
|
||||
};
|
||||
|
||||
// Click thumbnail to play live video
|
||||
this.previewContainer.addEventListener('click', () => {
|
||||
const videoId = this.extractVideoId(this.urlInput.value);
|
||||
if (videoId && !this.isIframeActive) {
|
||||
this.isIframeActive = true;
|
||||
this.previewContainer.innerHTML = '';
|
||||
const iframe = document.createElement('iframe');
|
||||
iframe.style.position = 'absolute';
|
||||
iframe.style.top = '0';
|
||||
iframe.style.left = '0';
|
||||
iframe.style.width = '100%';
|
||||
iframe.style.height = '100%';
|
||||
iframe.style.border = '0';
|
||||
iframe.setAttribute('allow', 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share');
|
||||
iframe.setAttribute('allowfullscreen', 'true');
|
||||
iframe.src = this.getEmbedUrl(videoId) + '?autoplay=1';
|
||||
this.previewContainer.appendChild(iframe);
|
||||
}
|
||||
});
|
||||
|
||||
updatePreview();
|
||||
|
||||
this.captionInput = document.createElement('input');
|
||||
this.captionInput.type = 'text';
|
||||
this.captionInput.className = 'form-control form-control-sm mb-2';
|
||||
this.captionInput.placeholder = 'Video caption (optional)...';
|
||||
this.captionInput.value = this.data.caption;
|
||||
|
||||
if (this.readOnly) {
|
||||
this.urlInput.disabled = true;
|
||||
this.thumbnailInput.disabled = true;
|
||||
mediaBtn.disabled = true;
|
||||
this.captionInput.disabled = true;
|
||||
}
|
||||
|
||||
this.urlInput.addEventListener('input', () => {
|
||||
const videoId = this.extractVideoId(this.urlInput.value);
|
||||
this.data.url = this.getEmbedUrl(videoId);
|
||||
this.isIframeActive = false;
|
||||
this.previewContainer.innerHTML = '';
|
||||
this.previewContainer.appendChild(playBtn);
|
||||
updatePreview();
|
||||
});
|
||||
|
||||
this.thumbnailInput.addEventListener('input', () => {
|
||||
this.data.thumbnailUrl = this.thumbnailInput.value.trim();
|
||||
updatePreview();
|
||||
});
|
||||
|
||||
this.captionInput.addEventListener('input', () => {
|
||||
this.data.caption = this.captionInput.value;
|
||||
});
|
||||
|
||||
container.appendChild(label);
|
||||
container.appendChild(this.urlInput);
|
||||
container.appendChild(thumbLabel);
|
||||
container.appendChild(thumbGroup);
|
||||
container.appendChild(this.previewContainer);
|
||||
container.appendChild(this.captionInput);
|
||||
|
||||
const stretchWrapper = document.createElement('div');
|
||||
stretchWrapper.className = 'custom-control custom-switch mt-2';
|
||||
this.fullWidthCheck = document.createElement('input');
|
||||
this.fullWidthCheck.type = 'checkbox';
|
||||
this.fullWidthCheck.className = 'custom-control-input';
|
||||
this.fullWidthCheck.id = 'yt_stretch_' + Math.random().toString(36).substring(7);
|
||||
this.fullWidthCheck.checked = !!(this.data && this.data.fullWidth);
|
||||
|
||||
const stretchLabel = document.createElement('label');
|
||||
stretchLabel.className = 'custom-control-label small font-weight-bold text-secondary';
|
||||
stretchLabel.htmlFor = this.fullWidthCheck.id;
|
||||
stretchLabel.innerHTML = '<i class="fas fa-arrows-alt-h"></i> Stretch block to Full Screen Width (Independent Breakout)';
|
||||
|
||||
this.fullWidthCheck.addEventListener('change', () => {
|
||||
this.data.fullWidth = this.fullWidthCheck.checked;
|
||||
});
|
||||
|
||||
stretchWrapper.appendChild(this.fullWidthCheck);
|
||||
stretchWrapper.appendChild(stretchLabel);
|
||||
container.appendChild(stretchWrapper);
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
save() {
|
||||
const videoId = this.extractVideoId(this.urlInput ? this.urlInput.value : this.data.url);
|
||||
return {
|
||||
url: this.getEmbedUrl(videoId) || this.data.url,
|
||||
thumbnailUrl: this.thumbnailInput ? this.thumbnailInput.value.trim() : (this.data.thumbnailUrl || ''),
|
||||
caption: this.captionInput ? this.captionInput.value : this.data.caption,
|
||||
fullWidth: this.fullWidthCheck ? this.fullWidthCheck.checked : !!(this.data && this.data.fullWidth)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
window.SISYouTubeTool = SISYouTubeTool;
|
||||
})();
|
||||
@@ -78,120 +78,155 @@
|
||||
/* ── Body ─── */
|
||||
'<div class="modal-body bg-light p-3" style="max-height: 85vh; overflow-y: auto;">' +
|
||||
|
||||
/* Toolbar */
|
||||
'<div class="d-flex flex-wrap justify-content-between align-items-center mb-3 bg-white p-2 border rounded shadow-sm">' +
|
||||
'<div class="input-group input-group-sm mb-2 mb-md-0" style="max-width: 380px;">' +
|
||||
'<div class="input-group-prepend"><span class="input-group-text bg-light"><i class="fas fa-search text-muted"></i></span></div>' +
|
||||
'<input type="text" class="form-control" id="sisMediaSearchInput" placeholder="Tìm kiếm theo tên file..." autocomplete="off" />' +
|
||||
'</div>' +
|
||||
'<div class="d-flex align-items-center">' +
|
||||
'<button type="button" class="btn btn-sm btn-outline-secondary mr-2" id="sisMediaRefreshBtn" title="Tải lại danh sách">' +
|
||||
'<i class="fas fa-sync-alt"></i>' +
|
||||
/* Config Panel (Positioned at top for fast editing of pre-selected images) */
|
||||
'<div id="' + this.configPanelId + '" class="mb-3 p-3 bg-white border rounded shadow-sm" style="display: none;">' +
|
||||
'<div class="d-flex justify-content-between align-items-center mb-3 pb-2 border-bottom">' +
|
||||
'<h6 class="font-weight-bold text-primary m-0"><i class="fas fa-sliders-h mr-1"></i> Cấu hình hình ảnh đã chọn</h6>' +
|
||||
'<button type="button" class="btn btn-sm btn-outline-primary shadow-sm" id="sisMediaChangeImageBtn">' +
|
||||
'<i class="fas fa-exchange-alt mr-1"></i> Đổi sang ảnh khác từ thư viện' +
|
||||
'</button>' +
|
||||
'<label class="btn btn-sm btn-success mb-0 shadow-sm" style="cursor: pointer;">' +
|
||||
'<i class="fas fa-cloud-upload-alt mr-1"></i> Tải ảnh mới lên' +
|
||||
'<input type="file" id="sisMediaUploadInput" accept="image/*" style="display: none;" />' +
|
||||
'</label>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
|
||||
/* Info bar */
|
||||
'<div id="sisMediaInfoBar" class="small text-muted mb-2 px-1" style="min-height: 18px;"></div>' +
|
||||
|
||||
/* Grid */
|
||||
'<div id="sisMediaGrid" class="row" style="max-height: 340px; overflow-y: auto; align-content: start;">' +
|
||||
'<div class="col-12 text-center text-muted py-5"><i class="fas fa-spinner fa-spin fa-2x"></i><br/><span class="mt-2 d-block">Đang tải...</span></div>' +
|
||||
'</div>' +
|
||||
|
||||
/* Lazy-load sentinel */
|
||||
'<div id="sisMediaSentinel" style="height: 1px;"></div>' +
|
||||
|
||||
/* Config Panel */
|
||||
'<div id="' + this.configPanelId + '" class="mt-3 p-3 bg-white border rounded shadow-sm" style="display: none;">' +
|
||||
'<h6 class="font-weight-bold text-primary mb-3"><i class="fas fa-sliders-h mr-1"></i> Cấu hình hình ảnh đã chọn</h6>' +
|
||||
'<div class="row">' +
|
||||
/* Preview */
|
||||
'<div class="col-md-3 text-center mb-3">' +
|
||||
'<p class="small text-muted mb-1 font-weight-bold">Xem trước</p>' +
|
||||
'<div class="border rounded p-2 bg-light" style="min-height: 100px; display: flex; align-items: center; justify-content: center;">' +
|
||||
'<div class="border rounded p-2 bg-light mb-2" style="min-height: 100px; display: flex; align-items: center; justify-content: center;">' +
|
||||
'<img id="sisMediaConfigPreview" src="" alt="Preview" style="max-width: 100%; max-height: 150px; object-fit: contain;" />' +
|
||||
'</div>' +
|
||||
'<p id="sisMediaConfigName" class="small text-truncate mt-1 text-muted mb-0"></p>' +
|
||||
'<p id="sisMediaConfigName" class="small text-truncate mt-1 text-muted mb-2"></p>' +
|
||||
'<button type="button" class="btn btn-xs btn-outline-secondary w-100 mb-1" id="sisMediaChangeImageBtn2">' +
|
||||
'<i class="fas fa-images mr-1"></i> Chọn ảnh khác từ thư viện' +
|
||||
'</button>' +
|
||||
'</div>' +
|
||||
/* Config fields */
|
||||
'<div class="col-md-9">' +
|
||||
'<div class="row">' +
|
||||
/* Quick sizes */
|
||||
'<div class="col-12 mb-2">' +
|
||||
'<label class="small font-weight-bold text-muted d-block mb-1" style="font-size:10px;text-transform:uppercase;">Kích thước nhanh</label>' +
|
||||
'<div id="sisMediaSizeButtons" class="d-flex flex-wrap" style="gap:4px;"></div>' +
|
||||
'</div>' +
|
||||
/* Width */
|
||||
'<div class="col-md-4 mb-2">' +
|
||||
'<label class="small font-weight-bold text-muted mb-1" style="font-size:10px;">Chiều rộng (width)</label>' +
|
||||
'<input type="text" id="sisMediaConfigWidth" class="form-control form-control-sm" placeholder="e.g. 100%, 300px, auto" />' +
|
||||
'</div>' +
|
||||
/* Height */
|
||||
'<div class="col-md-4 mb-2">' +
|
||||
'<label class="small font-weight-bold text-muted mb-1" style="font-size:10px;">Chiều cao (height)</label>' +
|
||||
'<input type="text" id="sisMediaConfigHeight" class="form-control form-control-sm" placeholder="e.g. auto, 200px" />' +
|
||||
'</div>' +
|
||||
/* Object Fit */
|
||||
'<div class="col-md-4 mb-2">' +
|
||||
'<label class="small font-weight-bold text-muted mb-1" style="font-size:10px;">Object Fit</label>' +
|
||||
'<select id="sisMediaConfigObjectFit" class="form-control form-control-sm">' +
|
||||
'<option value="">-- default --</option>' +
|
||||
'<option value="cover">cover</option>' +
|
||||
'<option value="contain">contain</option>' +
|
||||
'<option value="fill">fill</option>' +
|
||||
'<option value="none">none</option>' +
|
||||
'<option value="scale-down">scale-down</option>' +
|
||||
'</select>' +
|
||||
'</div>' +
|
||||
/* Aspect Ratio */
|
||||
'<div class="col-md-4 mb-2">' +
|
||||
'<label class="small font-weight-bold text-muted mb-1" style="font-size:10px;">Aspect Ratio</label>' +
|
||||
'<select id="sisMediaConfigAspectRatio" class="form-control form-control-sm">' +
|
||||
'<option value="">-- none --</option>' +
|
||||
'<option value="1/1">1:1 (Square)</option>' +
|
||||
'<option value="4/3">4:3 (Standard)</option>' +
|
||||
'<option value="16/9">16:9 (Widescreen)</option>' +
|
||||
'<option value="21/9">21:9 (Ultrawide)</option>' +
|
||||
'<option value="3/4">3:4 (Portrait)</option>' +
|
||||
'<option value="9/16">9:16 (Vertical Video)</option>' +
|
||||
'<option value="2/1">2:1 (Panorama)</option>' +
|
||||
'<option value="3/2">3:2 (Photo)</option>' +
|
||||
'<option value="5/4">5:4</option>' +
|
||||
'</select>' +
|
||||
'</div>' +
|
||||
/* CSS Class */
|
||||
'<div class="col-md-4 mb-2">' +
|
||||
'<label class="small font-weight-bold text-muted mb-1" style="font-size:10px;">CSS Class</label>' +
|
||||
'<input type="text" id="sisMediaConfigClass" class="form-control form-control-sm" placeholder="e.g. img-fluid rounded shadow" />' +
|
||||
'</div>' +
|
||||
/* Inline Style */
|
||||
'<div class="col-md-6 mb-2">' +
|
||||
'<label class="small font-weight-bold text-muted mb-1" style="font-size:10px;">Inline Style (CSS)</label>' +
|
||||
'<input type="text" id="sisMediaConfigStyle" class="form-control form-control-sm" placeholder="e.g. border-radius: 8px; opacity: 0.9;" />' +
|
||||
'</div>' +
|
||||
/* Alt Text */
|
||||
'<div class="col-md-6 mb-2">' +
|
||||
'<label class="small font-weight-bold text-muted mb-1" style="font-size:10px;">Alt Text</label>' +
|
||||
'<input type="text" id="sisMediaConfigAlt" class="form-control form-control-sm" placeholder="Image description..." />' +
|
||||
'</div>' +
|
||||
/* Custom Attributes */
|
||||
'<div class="col-md-6 mb-2">' +
|
||||
'<label class="small font-weight-bold text-muted mb-1" style="font-size:10px;">Custom Attributes (HTML)</label>' +
|
||||
'<input type="text" id="sisMediaConfigAttrs" class="form-control form-control-sm" placeholder=\'e.g. data-id="5" loading="lazy"\' />' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
/* Quick sizes dropdown */
|
||||
'<div class="col-md-4 mb-2">' +
|
||||
'<label class="small font-weight-bold text-muted mb-1" style="font-size:10px;text-transform:uppercase;">Kích thước nhanh</label>' +
|
||||
'<select id="sisMediaConfigQuickSize" class="form-control form-control-sm">' +
|
||||
'<option value="">-- none --</option>' +
|
||||
'<option value="auto,auto">Auto</option>' +
|
||||
'<option value="25%,auto">25%</option>' +
|
||||
'<option value="50%,auto">50%</option>' +
|
||||
'<option value="75%,auto">75%</option>' +
|
||||
'<option value="100%,auto">100%</option>' +
|
||||
'<option value="100px,auto">100px</option>' +
|
||||
'<option value="150px,auto">150px</option>' +
|
||||
'<option value="200px,auto">200px</option>' +
|
||||
'<option value="300px,auto">300px</option>' +
|
||||
'<option value="400px,auto">400px</option>' +
|
||||
'<option value="500px,auto">500px</option>' +
|
||||
'<option value="100%,auto">Full Width (100%)</option>' +
|
||||
'<option value="80px,80px">Thumb (80×80)</option>' +
|
||||
'<option value="120px,120px">Avatar (120×120)</option>' +
|
||||
'<option value="1200px,400px">Banner (1200×400)</option>' +
|
||||
'</select>' +
|
||||
'</div>' +
|
||||
/* Width */
|
||||
'<div class="col-md-4 mb-2">' +
|
||||
'<label class="small font-weight-bold text-muted mb-1" style="font-size:10px;">Chiều rộng (width)</label>' +
|
||||
'<input type="text" id="sisMediaConfigWidth" class="form-control form-control-sm" placeholder="e.g. 100%, 300px, auto" />' +
|
||||
'</div>' +
|
||||
/* Height */
|
||||
'<div class="col-md-4 mb-2">' +
|
||||
'<label class="small font-weight-bold text-muted mb-1" style="font-size:10px;">Chiều cao (height)</label>' +
|
||||
'<input type="text" id="sisMediaConfigHeight" class="form-control form-control-sm" placeholder="e.g. auto, 200px" />' +
|
||||
'</div>' +
|
||||
/* Object Fit */
|
||||
'<div class="col-md-4 mb-2">' +
|
||||
'<label class="small font-weight-bold text-muted mb-1" style="font-size:10px;">Object Fit</label>' +
|
||||
'<select id="sisMediaConfigObjectFit" class="form-control form-control-sm">' +
|
||||
'<option value="">-- default --</option>' +
|
||||
'<option value="cover">cover</option>' +
|
||||
'<option value="contain">contain</option>' +
|
||||
'<option value="fill">fill</option>' +
|
||||
'<option value="none">none</option>' +
|
||||
'<option value="scale-down">scale-down</option>' +
|
||||
'</select>' +
|
||||
'</div>' +
|
||||
/* Aspect Ratio */
|
||||
'<div class="col-md-4 mb-2">' +
|
||||
'<label class="small font-weight-bold text-muted mb-1" style="font-size:10px;">Aspect Ratio</label>' +
|
||||
'<select id="sisMediaConfigAspectRatio" class="form-control form-control-sm">' +
|
||||
'<option value="">-- none --</option>' +
|
||||
'<option value="1/1">1:1 (Square)</option>' +
|
||||
'<option value="4/3">4:3 (Standard)</option>' +
|
||||
'<option value="16/9">16:9 (Widescreen)</option>' +
|
||||
'<option value="21/9">21:9 (Ultrawide)</option>' +
|
||||
'<option value="3/4">3:4 (Portrait)</option>' +
|
||||
'<option value="9/16">9:16 (Vertical Video)</option>' +
|
||||
'<option value="2/1">2:1 (Panorama)</option>' +
|
||||
'<option value="3/2">3:2 (Photo)</option>' +
|
||||
'<option value="5/4">5:4</option>' +
|
||||
'</select>' +
|
||||
'</div>' +
|
||||
/* Element ID */
|
||||
'<div class="col-md-4 mb-2">' +
|
||||
'<label class="small font-weight-bold text-muted mb-1" style="font-size:10px;">Element ID</label>' +
|
||||
'<input type="text" id="sisMediaConfigId" class="form-control form-control-sm" placeholder="e.g. media-img-1" />' +
|
||||
'</div>' +
|
||||
/* File Class (CSS Class) */
|
||||
'<div class="col-md-6 mb-2">' +
|
||||
'<label class="small font-weight-bold text-muted mb-1" style="font-size:10px;">File Class (CSS Class)</label>' +
|
||||
'<input type="text" id="sisMediaConfigClass" class="form-control form-control-sm" placeholder="e.g. img-fluid rounded shadow" />' +
|
||||
'</div>' +
|
||||
/* Alt Text */
|
||||
'<div class="col-md-6 mb-2">' +
|
||||
'<label class="small font-weight-bold text-muted mb-1" style="font-size:10px;">Alt Text</label>' +
|
||||
'<input type="text" id="sisMediaConfigAlt" class="form-control form-control-sm" placeholder="Image description..." />' +
|
||||
'</div>' +
|
||||
/* Inline Style */
|
||||
'<div class="col-md-6 mb-2">' +
|
||||
'<label class="small font-weight-bold text-muted mb-1" style="font-size:10px;">Inline Style (CSS)</label>' +
|
||||
'<input type="text" id="sisMediaConfigStyle" class="form-control form-control-sm" placeholder="e.g. border-radius: 8px; opacity: 0.9;" />' +
|
||||
'</div>' +
|
||||
/* Custom Attributes */
|
||||
'<div class="col-md-6 mb-2">' +
|
||||
'<label class="small font-weight-bold text-muted mb-1" style="font-size:10px;">Custom Attributes (HTML)</label>' +
|
||||
'<input type="text" id="sisMediaConfigAttrs" class="form-control form-control-sm" placeholder=\'e.g. data-id="5" loading="lazy"\' />' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="text-right mt-2">' +
|
||||
'<button type="button" class="btn btn-sm btn-secondary mr-2" id="sisMediaConfigCancel">Hủy</button>' +
|
||||
'<button type="button" class="btn btn-sm btn-primary" id="sisMediaConfigConfirm"><i class="fas fa-check mr-1"></i> Chọn ảnh này</button>' +
|
||||
'<button type="button" class="btn btn-sm btn-primary shadow-sm" id="sisMediaConfigConfirm"><i class="fas fa-check mr-1"></i> Chọn / Cập nhật ảnh này</button>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
|
||||
/* Library Grid Wrapper */
|
||||
'<div id="sisMediaGridWrapper">' +
|
||||
/* Toolbar */
|
||||
'<div class="d-flex flex-wrap justify-content-between align-items-center mb-3 bg-white p-2 border rounded shadow-sm">' +
|
||||
'<div class="input-group input-group-sm mb-2 mb-md-0" style="max-width: 380px;">' +
|
||||
'<div class="input-group-prepend"><span class="input-group-text bg-light"><i class="fas fa-search text-muted"></i></span></div>' +
|
||||
'<input type="text" class="form-control" id="sisMediaSearchInput" placeholder="Tìm kiếm theo tên file..." autocomplete="off" />' +
|
||||
'</div>' +
|
||||
'<div class="d-flex align-items-center">' +
|
||||
'<button type="button" class="btn btn-sm btn-outline-secondary mr-2" id="sisMediaRefreshBtn" title="Tải lại danh sách">' +
|
||||
'<i class="fas fa-sync-alt"></i>' +
|
||||
'</button>' +
|
||||
'<label class="btn btn-sm btn-success mb-0 shadow-sm" style="cursor: pointer;">' +
|
||||
'<i class="fas fa-cloud-upload-alt mr-1"></i> Tải ảnh mới lên' +
|
||||
'<input type="file" id="sisMediaUploadInput" accept="image/*" style="display: none;" />' +
|
||||
'</label>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
|
||||
/* Info bar */
|
||||
'<div id="sisMediaInfoBar" class="small text-muted mb-2 px-1" style="min-height: 18px;"></div>' +
|
||||
|
||||
/* Grid */
|
||||
'<div id="sisMediaGrid" class="row" style="max-height: 340px; overflow-y: auto; align-content: start;">' +
|
||||
'<div class="col-12 text-center text-muted py-5"><i class="fas fa-spinner fa-spin fa-2x"></i><br/><span class="mt-2 d-block">Đang tải...</span></div>' +
|
||||
'</div>' +
|
||||
|
||||
/* Lazy-load sentinel */
|
||||
'<div id="sisMediaSentinel" style="height: 1px;"></div>' +
|
||||
'</div>' +
|
||||
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
|
||||
/* Footer */
|
||||
@@ -243,7 +278,7 @@
|
||||
}
|
||||
});
|
||||
|
||||
// Upload
|
||||
// Upload & Quick Size dropdown
|
||||
modalEl.addEventListener('change', function (e) {
|
||||
if (e.target && e.target.id === 'sisMediaUploadInput') {
|
||||
var file = e.target.files && e.target.files[0];
|
||||
@@ -251,13 +286,36 @@
|
||||
self._doUpload(file);
|
||||
// reset so same file can be uploaded again
|
||||
e.target.value = '';
|
||||
} else if (e.target && e.target.id === 'sisMediaConfigQuickSize') {
|
||||
var val = e.target.value;
|
||||
if (!val) return;
|
||||
var parts = val.split(',');
|
||||
var w = parts[0] || '';
|
||||
var h = parts[1] || '';
|
||||
var wEl = document.getElementById('sisMediaConfigWidth');
|
||||
var hEl = document.getElementById('sisMediaConfigHeight');
|
||||
if (wEl) wEl.value = w;
|
||||
if (hEl) hEl.value = h;
|
||||
}
|
||||
});
|
||||
|
||||
// Button clicks
|
||||
modalEl.addEventListener('click', function (e) {
|
||||
var id = e.target && e.target.id;
|
||||
var changeBtn = e.target && e.target.closest && (e.target.closest('#sisMediaChangeImageBtn') || e.target.closest('#sisMediaChangeImageBtn2'));
|
||||
var refreshBtn = e.target && e.target.closest && e.target.closest('#sisMediaRefreshBtn');
|
||||
|
||||
if (changeBtn || id === 'sisMediaChangeImageBtn' || id === 'sisMediaChangeImageBtn2') {
|
||||
var gridWrapper = document.getElementById('sisMediaGridWrapper');
|
||||
if (gridWrapper) {
|
||||
gridWrapper.style.display = 'block';
|
||||
gridWrapper.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}
|
||||
if (!self.currentPage && !self.loading) {
|
||||
self._resetAndFetch();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (refreshBtn || id === 'sisMediaRefreshBtn') {
|
||||
self._resetAndFetch(); return;
|
||||
}
|
||||
@@ -280,7 +338,28 @@
|
||||
},
|
||||
|
||||
/* ── open ────────────────────────────────────────────── */
|
||||
open: function (callback) {
|
||||
open: function (arg1, arg2, arg3) {
|
||||
var callback = null;
|
||||
var initialUrl = null;
|
||||
var initialConfig = null;
|
||||
|
||||
if (typeof arg1 === 'function') {
|
||||
callback = arg1;
|
||||
if (typeof arg2 === 'string') {
|
||||
initialUrl = arg2;
|
||||
initialConfig = arg3 || {};
|
||||
} else if (typeof arg2 === 'object' && arg2 !== null) {
|
||||
initialUrl = arg2.url || arg2.currentUrl || arg2.initialUrl || arg2.fileUrl || '';
|
||||
initialConfig = arg2.config || arg2.currentConfig || arg2.initialConfig || arg2;
|
||||
}
|
||||
} else if (typeof arg1 === 'object' && arg1 !== null) {
|
||||
initialUrl = arg1.url || arg1.currentUrl || arg1.initialUrl || arg1.fileUrl || '';
|
||||
initialConfig = arg1.config || arg1.currentConfig || arg1.initialConfig || arg1;
|
||||
if (typeof arg2 === 'function') {
|
||||
callback = arg2;
|
||||
}
|
||||
}
|
||||
|
||||
this.callback = callback;
|
||||
var modalEl = this.initModal();
|
||||
|
||||
@@ -296,7 +375,18 @@
|
||||
if (searchEl) searchEl.value = '';
|
||||
this.currentKeyword = '';
|
||||
|
||||
this._resetAndFetch();
|
||||
var gridWrapper = document.getElementById('sisMediaGridWrapper');
|
||||
|
||||
if (initialUrl) {
|
||||
// Image previously selected: show Config Panel immediately, hide library grid until user clicks "Đổi ảnh khác"
|
||||
if (gridWrapper) gridWrapper.style.display = 'none';
|
||||
var filename = initialUrl.split('/').pop();
|
||||
this.showConfigPanel(initialUrl, { url: initialUrl, name: filename }, initialConfig);
|
||||
} else {
|
||||
// New selection: show library grid immediately
|
||||
if (gridWrapper) gridWrapper.style.display = 'block';
|
||||
this._resetAndFetch();
|
||||
}
|
||||
},
|
||||
|
||||
/* ── internal helpers ───────────────────────────────── */
|
||||
@@ -497,7 +587,7 @@
|
||||
},
|
||||
|
||||
/* ── Config Panel ────────────────────────────────────── */
|
||||
showConfigPanel: function (url, mediaObj) {
|
||||
showConfigPanel: function (url, mediaObj, initialConfig) {
|
||||
this.selectedUrl = url;
|
||||
this.selectedMediaObj = mediaObj || {};
|
||||
|
||||
@@ -507,22 +597,52 @@
|
||||
var previewImg = document.getElementById('sisMediaConfigPreview');
|
||||
if (previewImg) previewImg.src = url;
|
||||
|
||||
var filename = (mediaObj && (mediaObj.originalFilename || mediaObj.name)) || (url ? url.split('/').pop() : '');
|
||||
var nameEl = document.getElementById('sisMediaConfigName');
|
||||
if (nameEl) nameEl.textContent = (mediaObj && (mediaObj.originalFilename || mediaObj.name)) || '';
|
||||
if (nameEl) nameEl.textContent = filename;
|
||||
|
||||
// Reset all fields
|
||||
['sisMediaConfigWidth','sisMediaConfigHeight','sisMediaConfigStyle','sisMediaConfigClass','sisMediaConfigAlt','sisMediaConfigAttrs'].forEach(function (id) {
|
||||
['sisMediaConfigWidth','sisMediaConfigHeight','sisMediaConfigStyle','sisMediaConfigClass','sisMediaConfigId','sisMediaConfigAlt','sisMediaConfigAttrs'].forEach(function (id) {
|
||||
var el = document.getElementById(id); if (el) el.value = '';
|
||||
});
|
||||
['sisMediaConfigObjectFit','sisMediaConfigAspectRatio'].forEach(function (id) {
|
||||
['sisMediaConfigQuickSize','sisMediaConfigObjectFit','sisMediaConfigAspectRatio'].forEach(function (id) {
|
||||
var el = document.getElementById(id); if (el) el.value = '';
|
||||
});
|
||||
|
||||
var sizeContainer = document.getElementById('sisMediaSizeButtons');
|
||||
if (sizeContainer) {
|
||||
sizeContainer.querySelectorAll('button').forEach(function (b) {
|
||||
b.classList.replace('btn-secondary', 'btn-outline-secondary');
|
||||
});
|
||||
// Pre-fill initialConfig if provided
|
||||
if (initialConfig) {
|
||||
var setVal = function(id, val) {
|
||||
if (val !== undefined && val !== null && val !== '') {
|
||||
var el = document.getElementById(id);
|
||||
if (el) el.value = val;
|
||||
}
|
||||
};
|
||||
setVal('sisMediaConfigWidth', initialConfig.width);
|
||||
setVal('sisMediaConfigHeight', initialConfig.height);
|
||||
setVal('sisMediaConfigObjectFit', initialConfig.objectFit);
|
||||
setVal('sisMediaConfigAspectRatio', initialConfig.aspectRatio);
|
||||
setVal('sisMediaConfigId', initialConfig.id || initialConfig.elementId);
|
||||
setVal('sisMediaConfigClass', initialConfig.cssClass || initialConfig.class);
|
||||
setVal('sisMediaConfigStyle', initialConfig.style);
|
||||
setVal('sisMediaConfigAlt', initialConfig.alt);
|
||||
setVal('sisMediaConfigAttrs', initialConfig.customAttributes || initialConfig.customAttrs);
|
||||
|
||||
// Parse style string if width/height/fit/aspect fields are empty
|
||||
if (initialConfig.style && typeof initialConfig.style === 'string') {
|
||||
var styleStr = initialConfig.style;
|
||||
var matchProp = function(prop) {
|
||||
var m = styleStr.match(new RegExp('(?:^|;\\s*)' + prop + '\\s*:\\s*([^;]+)', 'i'));
|
||||
return m ? m[1].trim() : '';
|
||||
};
|
||||
var w = matchProp('width');
|
||||
var h = matchProp('height');
|
||||
var fit = matchProp('object-fit');
|
||||
var ratio = matchProp('aspect-ratio');
|
||||
if (w && !document.getElementById('sisMediaConfigWidth').value) document.getElementById('sisMediaConfigWidth').value = w;
|
||||
if (h && !document.getElementById('sisMediaConfigHeight').value) document.getElementById('sisMediaConfigHeight').value = h;
|
||||
if (fit && !document.getElementById('sisMediaConfigObjectFit').value) document.getElementById('sisMediaConfigObjectFit').value = fit;
|
||||
if (ratio && !document.getElementById('sisMediaConfigAspectRatio').value) document.getElementById('sisMediaConfigAspectRatio').value = ratio;
|
||||
}
|
||||
}
|
||||
|
||||
panel.style.display = 'block';
|
||||
@@ -546,6 +666,7 @@
|
||||
var height = g('sisMediaConfigHeight');
|
||||
var objectFit = g('sisMediaConfigObjectFit');
|
||||
var aspectRatio= g('sisMediaConfigAspectRatio');
|
||||
var elementId = g('sisMediaConfigId');
|
||||
var style = g('sisMediaConfigStyle');
|
||||
var cssClass = g('sisMediaConfigClass');
|
||||
var alt = g('sisMediaConfigAlt');
|
||||
@@ -560,9 +681,17 @@
|
||||
computedStyle = computedStyle.trim();
|
||||
|
||||
var config = {
|
||||
style: computedStyle, cssClass: cssClass, alt: alt,
|
||||
customAttributes: customAttrs, width: width, height: height,
|
||||
objectFit: objectFit, aspectRatio: aspectRatio
|
||||
id: elementId,
|
||||
elementId: elementId,
|
||||
style: computedStyle,
|
||||
cssClass: cssClass,
|
||||
class: cssClass,
|
||||
alt: alt,
|
||||
customAttributes: customAttrs,
|
||||
width: width,
|
||||
height: height,
|
||||
objectFit: objectFit,
|
||||
aspectRatio: aspectRatio
|
||||
};
|
||||
|
||||
if (typeof this.callback === 'function') {
|
||||
|
||||
@@ -462,6 +462,10 @@
|
||||
<script th:src="@{/js/manage/editor-plugins/text-styling.js}"></script>
|
||||
<script th:src="@{/js/manage/editor-plugins/tiny-mce.js}"></script>
|
||||
<script th:src="@{/js/manage/editor-plugins/posts.js}"></script>
|
||||
<script th:src="@{/js/manage/editor-plugins/tab-plugin.js}"></script>
|
||||
<script th:src="@{/js/manage/editor-plugins/custom-image.js}"></script>
|
||||
<script th:src="@{/js/manage/editor-plugins/cms_plugin.js}"></script>
|
||||
<script th:src="@{/js/manage/editor-plugins/youtube-plugin.js}"></script>
|
||||
|
||||
<!-- SIS Standalone Media Picker Library -->
|
||||
<script th:src="@{/js/manage/sis-media-picker.js}"></script>
|
||||
|
||||
@@ -412,7 +412,7 @@
|
||||
'bold italic backcolor | alignleft aligncenter ' +
|
||||
'alignright alignjustify | bullist numlist outdent indent | ' +
|
||||
'table tablemergecells tablesplitcells | ' +
|
||||
'insertYouTube | insertTabStart insertTabEnd editClassId | previewTabs | removeformat | help',
|
||||
'insertYouTube | insertTabStart insertTabEnd insertMultiTabPanel editClassId | previewTabs | removeformat | help',
|
||||
toolbar_mode: 'wrap',
|
||||
content_style: `
|
||||
body { font-family:Helvetica,Arial,sans-serif; font-size:16px; }
|
||||
@@ -454,6 +454,15 @@
|
||||
font-weight: 600;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.sis-multi-tab-wrapper {
|
||||
border: 1px dashed #ccc;
|
||||
border-radius: 6px;
|
||||
padding: 0;
|
||||
margin: 24px 0;
|
||||
}
|
||||
.sis-multi-tab-wrapper [data-sis-tab-panel] {
|
||||
font-family: inherit;
|
||||
}
|
||||
`,
|
||||
// Convert URLs to absolute if needed, or keep relative
|
||||
convert_urls: false,
|
||||
@@ -491,8 +500,8 @@
|
||||
});
|
||||
|
||||
editor.ui.registry.addButton('insertTabStart', {
|
||||
text: 'Thêm Tab',
|
||||
tooltip: 'Tạo một Tab ngang mới (Dành cho Chi tiết khoá học)',
|
||||
text: '📑 Thêm Tab',
|
||||
tooltip: 'Tạo một tab mới (đặt tên tab, rồi nhập nội dung vào trong)',
|
||||
onAction: function (_) {
|
||||
var tabName = prompt('Nhập tiêu đề Tab (ví dụ: Tổng quan):', 'Tiêu đề Tab');
|
||||
if (tabName) {
|
||||
@@ -502,13 +511,115 @@
|
||||
});
|
||||
|
||||
editor.ui.registry.addButton('insertTabEnd', {
|
||||
text: 'Kết thúc Tab',
|
||||
text: '⏹ Kết thúc Tab',
|
||||
tooltip: 'Đóng/Kết thúc nhóm Tab hiện tại',
|
||||
onAction: function (_) {
|
||||
editor.insertContent('<p> </p><hr class="dynamic-tab-end"/><p> </p>');
|
||||
},
|
||||
});
|
||||
|
||||
// ─── Multi-Tab Panel Builder ─────────────────────────────────────────────
|
||||
editor.ui.registry.addButton('insertMultiTabPanel', {
|
||||
text: '🗂 Tạo Multi-Tab',
|
||||
tooltip: 'Tạo khung nhiều Tab (có thể chứa bảng, hình ảnh, văn bản...)',
|
||||
onAction: function (_) {
|
||||
// Unique ID for this panel
|
||||
var uid = 'tab-panel-' + Date.now();
|
||||
|
||||
editor.windowManager.open({
|
||||
title: 'Tạo Khung Multi-Tab',
|
||||
size: 'medium',
|
||||
body: {
|
||||
type: 'panel',
|
||||
items: [
|
||||
{
|
||||
type: 'htmlpanel',
|
||||
html: [
|
||||
'<div style="padding:4px 0 12px 0">',
|
||||
'<p style="color:#555;margin:0 0 10px 0;font-size:13px;">',
|
||||
'📌 Nhập tên các Tab (mỗi dòng một Tab). Sau khi tạo, click vào từng tab để nhập nội dung.',
|
||||
'</p>',
|
||||
'<textarea id="sisTabNamesInput" rows="5" style="width:100%;padding:8px;border:1px solid #ccc;border-radius:4px;font-size:13px;font-family:sans-serif;resize:vertical;" placeholder="Tổng quan Thông tin chung Lịch khai giảng Học phí"></textarea>',
|
||||
'<div style="margin-top:8px">',
|
||||
'<label style="font-size:12px;color:#666;font-weight:600;">🎨 Màu nền Tab header:</label>',
|
||||
'<input type="color" id="sisTabColorInput" value="#881c1c" style="margin-left:8px;width:40px;height:28px;border:none;padding:0;cursor:pointer;" />',
|
||||
'<span id="sisTabColorHex" style="margin-left:6px;font-size:12px;font-family:monospace;color:#555;">#881c1c</span>',
|
||||
'</div>',
|
||||
'</div>',
|
||||
].join(''),
|
||||
},
|
||||
],
|
||||
},
|
||||
buttons: [
|
||||
{ type: 'cancel', text: 'Hủy' },
|
||||
{
|
||||
type: 'submit',
|
||||
text: '✅ Tạo Tab ngay',
|
||||
primary: true,
|
||||
onAction: function () {},
|
||||
},
|
||||
],
|
||||
onSubmit: function (api) {
|
||||
var textarea = document.getElementById('sisTabNamesInput');
|
||||
var colorInput = document.getElementById('sisTabColorInput');
|
||||
var rawNames = textarea ? textarea.value : '';
|
||||
var color = colorInput ? colorInput.value : '#881c1c';
|
||||
|
||||
var tabNames = rawNames
|
||||
.split('\n')
|
||||
.map(function (s) { return s.trim(); })
|
||||
.filter(function (s) { return s.length > 0; });
|
||||
|
||||
if (tabNames.length === 0) {
|
||||
alert('Bạn chưa nhập tên Tab nào!');
|
||||
return;
|
||||
}
|
||||
|
||||
// Build tab HTML
|
||||
var tabBtnsHtml = '';
|
||||
var tabPanelsHtml = '';
|
||||
tabNames.forEach(function (name, i) {
|
||||
var isActive = (i === 0);
|
||||
var btnStyle = isActive
|
||||
? 'background:' + color + ';color:#fff;border:2px solid ' + color + ';'
|
||||
: 'background:#fff;color:' + color + ';border:2px solid ' + color + ';border-left:none;';
|
||||
tabBtnsHtml += '<button type="button"'
|
||||
+ ' onclick="(function(btn){var wrap=btn.closest(\'[data-sis-tabs=\\\'' + uid + '\\\']\');wrap.querySelectorAll(\'[data-sis-tab-btn]\').forEach(function(b){b.style.background=\'#fff\';b.style.color=\'' + color + '\';});btn.style.background=\'' + color + '\';btn.style.color=\'#fff\';wrap.querySelectorAll(\'[data-sis-tab-panel]\').forEach(function(p){p.style.display=\'none\';});wrap.querySelector(\'[data-sis-tab-panel=\\\'' + i + '\\\']\').style.display=\'block\';})(this)"'
|
||||
+ ' data-sis-tab-btn="' + i + '"'
|
||||
+ ' style="' + btnStyle + 'padding:10px 20px;cursor:pointer;font-weight:700;font-size:14px;flex:1 1 auto;transition:all 0.2s;">'
|
||||
+ name
|
||||
+ '</button>';
|
||||
|
||||
tabPanelsHtml += '<div data-sis-tab-panel="' + i + '" style="display:' + (isActive ? 'block' : 'none') + ';padding:20px 16px;border:2px solid ' + color + ';border-top:none;border-radius:0 0 6px 6px;min-height:120px;">'
|
||||
+ '<p>Nội dung Tab <strong>' + name + '</strong> — hãy thay thế dòng này (có thể chèn bảng, hình ảnh, danh sách...).</p>'
|
||||
+ '</div>';
|
||||
});
|
||||
|
||||
var fullHtml = '<div data-sis-tabs="' + uid + '" class="sis-multi-tab-wrapper" style="margin:24px 0;">'
|
||||
+ '<div style="display:flex;flex-wrap:wrap;">' + tabBtnsHtml + '</div>'
|
||||
+ tabPanelsHtml
|
||||
+ '</div>'
|
||||
+ '<p> </p>';
|
||||
|
||||
editor.insertContent(fullHtml);
|
||||
api.close();
|
||||
},
|
||||
});
|
||||
|
||||
// Attach color preview listener after dialog opens
|
||||
setTimeout(function () {
|
||||
var colorEl = document.getElementById('sisTabColorInput');
|
||||
var hexEl = document.getElementById('sisTabColorHex');
|
||||
if (colorEl && hexEl) {
|
||||
colorEl.addEventListener('input', function () {
|
||||
hexEl.textContent = colorEl.value;
|
||||
});
|
||||
}
|
||||
}, 50);
|
||||
},
|
||||
});
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
editor.ui.registry.addButton('previewTabs', {
|
||||
text: '👁 Xem trước',
|
||||
tooltip: 'Xem trước bài viết với Tab hoạt động giống giao diện thực',
|
||||
|
||||
@@ -0,0 +1,420 @@
|
||||
<html
|
||||
lang="vi"
|
||||
xmlns:th="http://www.thymeleaf.org"
|
||||
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
|
||||
layout:decorate="~{fragments/manage-layout}"
|
||||
>
|
||||
<head>
|
||||
<title th:text="${pageTitle} + ' - SIS Vietnam'">Bảng giá Dịch vụ Settings</title>
|
||||
<style>
|
||||
.shortcode-badge {
|
||||
font-family: monospace;
|
||||
background: #eef2ff;
|
||||
color: #4338ca;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #c7d2fe;
|
||||
font-weight: 600;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
.cat-card-modal {
|
||||
border: 1px solid #e3e6f0;
|
||||
border-left: 4px solid #4e73df;
|
||||
margin-bottom: 1.25rem;
|
||||
padding: 1rem;
|
||||
border-radius: 6px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
.item-row-modal {
|
||||
background: #fff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 4px;
|
||||
padding: 0.6rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.copy-btn {
|
||||
cursor: pointer;
|
||||
transition: transform 0.1s;
|
||||
}
|
||||
.copy-btn:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div layout:fragment="content">
|
||||
<div class="container-fluid">
|
||||
<!-- Header -->
|
||||
<div class="d-sm-flex align-items-center justify-content-between mb-4">
|
||||
<h1 class="h3 mb-0 text-gray-800" th:text="${pageTitle}">Quản lý Đa Bảng giá Dịch vụ</h1>
|
||||
<div>
|
||||
<button type="button" class="btn btn-success font-weight-bold" onclick="openAddModal()">
|
||||
<i class="fas fa-plus-circle mr-1"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- System Alerts -->
|
||||
<div th:if="${successMessage}" class="alert alert-success alert-dismissible fade show" role="alert">
|
||||
<span th:text="${successMessage}"></span>
|
||||
<button type="button" class="close" data-dismiss="alert" aria-label="Close"><span aria-hidden="true">×</span></button>
|
||||
</div>
|
||||
<div th:if="${errorMessage}" class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||
<span th:text="${errorMessage}"></span>
|
||||
<button type="button" class="close" data-dismiss="alert" aria-label="Close"><span aria-hidden="true">×</span></button>
|
||||
</div>
|
||||
|
||||
<!-- Main Form -->
|
||||
<form method="post" th:action="@{/manage/plugins/price-table}" id="priceForm">
|
||||
<input type="hidden" name="tableData" id="tableData" />
|
||||
|
||||
<!-- Price Tables List View -->
|
||||
<div class="card shadow mb-4">
|
||||
<div class="card-header py-3 d-flex justify-content-between align-items-center bg-white text-primary">
|
||||
<h6 class="m-0 font-weight-bold"><i class="fas fa-list mr-2"></i> Danh sách các Bảng Giá trong hệ thống</h6>
|
||||
<button type="submit" class="btn btn-light btn-sm font-weight-bold text-primary px-3" onclick="return saveAllForms();">
|
||||
<i class="fas fa-save mr-1"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-bordered table-hover mb-0" id="mainTablesList">
|
||||
<thead class="thead-light">
|
||||
<tr>
|
||||
<th style="width: 50px" class="text-center">STT</th>
|
||||
<th style="width: 140px">ID (Slug)</th>
|
||||
<th>Tên Bảng Giá</th>
|
||||
<th>Tên Tabs</th>
|
||||
<th style="width: 120px" class="text-center">Nhóm / Dịch vụ</th>
|
||||
<th style="width: 320px">Shortcode nhúng</th>
|
||||
<th style="width: 160px" class="text-center">Thao tác</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="tablesTableBody">
|
||||
<!-- Populated dynamically by JS -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Edit / Add Modal Popup -->
|
||||
<div
|
||||
class="modal fade"
|
||||
id="tableEditModal"
|
||||
tabindex="-1"
|
||||
role="dialog"
|
||||
aria-labelledby="modalTitle"
|
||||
aria-hidden="true"
|
||||
data-backdrop="static"
|
||||
>
|
||||
<div class="modal-dialog modal-xl modal-dialog-scrollable" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header bg-primary text-white">
|
||||
<h5 class="modal-title font-weight-bold" id="modalTitle">Chỉnh sửa Bảng giá</h5>
|
||||
<button type="button" class="close text-white" onclick="closeModal()" aria-label="Close">
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body" style="background-color: #f8fafc">
|
||||
<input type="hidden" id="modalTableIndex" value="-1" />
|
||||
|
||||
<!-- Table General Settings -->
|
||||
<div class="card mb-4 shadow-sm">
|
||||
<div class="card-body">
|
||||
<div class="form-row">
|
||||
<div class="col-md-3 mb-3">
|
||||
<label class="font-weight-bold text-primary">ID Bảng giá (Slug)</label>
|
||||
<input type="text" id="modalTableId" class="form-control" placeholder="VD: kham-benh" />
|
||||
<small class="form-text text-muted">Dùng cho shortcode: <code>[plugin:price-table id="..."]</code></small>
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<label class="font-weight-bold text-primary">Tên Bảng giá</label>
|
||||
<input type="text" id="modalTableName" class="form-control" placeholder="VD: Bảng giá khám chữa bệnh" />
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<label class="font-weight-bold text-primary">Tên Tab 1</label>
|
||||
<input type="text" id="modalTab1Name" class="form-control" placeholder="Mặc định: Bảng giá" />
|
||||
</div>
|
||||
<div class="col-md-3 mb-3">
|
||||
<label class="font-weight-bold text-primary">Tên Tab 2</label>
|
||||
<input type="text" id="modalTab2Name" class="form-control" placeholder="Mặc định: Danh mục kỹ thuật" />
|
||||
</div>
|
||||
<div class="col-md-12 mb-2">
|
||||
<label class="font-weight-bold text-danger"
|
||||
><i class="far fa-file-pdf mr-1"></i> URL File PDF cho Tab 2 (Danh mục kỹ thuật)</label
|
||||
>
|
||||
<input type="text" id="modalPdfUrl" class="form-control" placeholder="VD: /uploads/files/danh-muc-ky-thuat.pdf" />
|
||||
<small class="form-text text-muted">File PDF hiển thị khi chuyển sang Tab 2 (Danh mục kỹ thuật).</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Categories & Items Editor -->
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h6 class="font-weight-bold text-dark m-0">
|
||||
<i class="fas fa-folder mr-1"></i> Các nhóm danh mục & dịch vụ kỹ thuật (Tab 1)
|
||||
</h6>
|
||||
<button type="button" class="btn btn-success btn-sm font-weight-bold" onclick="addCategoryInModal()">
|
||||
<i class="fas fa-folder-plus mr-1"></i> + Thêm Nhóm Dịch Vụ Mới
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="modalCategoriesContainer"></div>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer bg-light">
|
||||
<button type="button" class="btn btn-secondary font-weight-bold" onclick="closeModal()">Hủy bỏ</button>
|
||||
<button type="button" class="btn btn-primary font-weight-bold px-4" onclick="saveModalChanges()">
|
||||
<i class="fas fa-check mr-1"></i> Cập Nhật Vào Danh Sách
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- JavaScript logic -->
|
||||
<script th:inline="javascript">
|
||||
let tables = /*[[${tables}]]*/ [];
|
||||
if (!tables) tables = [];
|
||||
let currentModalCategories = [];
|
||||
|
||||
function renderMainTablesList() {
|
||||
const tbody = document.getElementById('tablesTableBody');
|
||||
tbody.innerHTML = '';
|
||||
|
||||
if (tables.length === 0) {
|
||||
tbody.innerHTML = `<tr><td colspan="7" class="text-center text-muted py-4">Chưa có bảng giá nào. Hãy bấm <b>"+ Thêm Bảng Giá Mới"</b> để tạo.</td></tr>`;
|
||||
return;
|
||||
}
|
||||
|
||||
tables.forEach((tbl, idx) => {
|
||||
const tr = document.createElement('tr');
|
||||
const tableId = tbl.id || 'table_' + (idx + 1);
|
||||
const categoriesCount = (tbl.categories || []).length;
|
||||
let itemsCount = 0;
|
||||
(tbl.categories || []).forEach(c => (itemsCount += (c.items || []).length));
|
||||
|
||||
const shortcodeDefault = `[plugin:price-table]`;
|
||||
const shortcodeId = `[plugin:price-table id="${escapeAttr(tableId)}"]`;
|
||||
|
||||
tr.innerHTML = `
|
||||
<td class="text-center font-weight-bold align-middle">${idx + 1}</td>
|
||||
<td class="align-middle"><code class="font-weight-bold text-primary">${escapeAttr(tableId)}</code></td>
|
||||
<td class="align-middle font-weight-bold text-dark">${escapeAttr(tbl.name || 'Bảng giá chưa đặt tên')}</td>
|
||||
<td class="align-middle"><small class="text-muted">${escapeAttr(tbl.tab1Name || 'Bảng giá')} / ${escapeAttr(tbl.tab2Name || 'Danh mục kỹ thuật')}</small></td>
|
||||
<td class="text-center align-middle"><span class="badge badge-secondary p-1">${categoriesCount} nhóm / ${itemsCount} dv</span></td>
|
||||
<td class="align-middle">
|
||||
<div class="d-flex flex-column gap-1">
|
||||
${idx === 0 ? `<div class="mb-1"><small class="text-muted mr-1">Mặc định:</small> <span class="shortcode-badge">${shortcodeDefault}</span> <button type="button" class="btn btn-link btn-sm p-0 ml-1 copy-btn" onclick="copyText('${shortcodeDefault}')" title="Copy"><i class="far fa-copy"></i></button></div>` : ''}
|
||||
<div><small class="text-muted mr-1">Theo ID:</small> <span class="shortcode-badge">${shortcodeId}</span> <button type="button" class="btn btn-link btn-sm p-0 ml-1 copy-btn" onclick="copyText('${shortcodeId}')" title="Copy"><i class="far fa-copy"></i></button></div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-center align-middle">
|
||||
<button type="button" class="btn btn-primary btn-sm mr-1 font-weight-bold" onclick="openEditModal(${idx})" title="Chỉnh sửa">
|
||||
<i class="fas fa-edit"></i> Sửa
|
||||
</button>
|
||||
<button type="button" class="btn btn-danger btn-sm font-weight-bold" onclick="deleteTable(${idx})" title="Xóa">
|
||||
<i class="fas fa-trash-alt"></i> Xóa
|
||||
</button>
|
||||
</td>
|
||||
`;
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
}
|
||||
|
||||
/* --- Modal Functions --- */
|
||||
|
||||
function openAddModal() {
|
||||
document.getElementById('modalTableIndex').value = -1;
|
||||
const newId = 'table_' + (tables.length + 1);
|
||||
document.getElementById('modalTableId').value = newId;
|
||||
document.getElementById('modalTableName').value = 'Bảng giá mới ' + (tables.length + 1);
|
||||
document.getElementById('modalTab1Name').value = 'Bảng giá';
|
||||
document.getElementById('modalTab2Name').value = 'Danh mục kỹ thuật';
|
||||
document.getElementById('modalPdfUrl').value = '';
|
||||
document.getElementById('modalTitle').innerText = 'Tạo Bảng Giá Mới';
|
||||
|
||||
currentModalCategories = [{ categoryName: 'KHÁM BỆNH', items: [] }];
|
||||
|
||||
renderModalCategories();
|
||||
$('#tableEditModal').modal('show');
|
||||
}
|
||||
|
||||
function openEditModal(idx) {
|
||||
const tbl = tables[idx];
|
||||
document.getElementById('modalTableIndex').value = idx;
|
||||
document.getElementById('modalTableId').value = tbl.id || '';
|
||||
document.getElementById('modalTableName').value = tbl.name || '';
|
||||
document.getElementById('modalTab1Name').value = tbl.tab1Name || 'Bảng giá';
|
||||
document.getElementById('modalTab2Name').value = tbl.tab2Name || 'Danh mục kỹ thuật';
|
||||
document.getElementById('modalPdfUrl').value = tbl.pdfUrl || '';
|
||||
document.getElementById('modalTitle').innerText = 'Chỉnh Sửa Bảng Giá: ' + (tbl.name || '');
|
||||
|
||||
currentModalCategories = JSON.parse(JSON.stringify(tbl.categories || []));
|
||||
renderModalCategories();
|
||||
$('#tableEditModal').modal('show');
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
$('#tableEditModal').modal('hide');
|
||||
}
|
||||
|
||||
function renderModalCategories() {
|
||||
const container = document.getElementById('modalCategoriesContainer');
|
||||
container.innerHTML = '';
|
||||
|
||||
if (currentModalCategories.length === 0) {
|
||||
container.innerHTML =
|
||||
'<div class="alert alert-info text-center">Chưa có nhóm dịch vụ nào. Bấm <b>"+ Thêm Nhóm Dịch Vụ Mới"</b> để tạo nhóm đầu tiên.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
currentModalCategories.forEach((cat, cIdx) => {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'cat-card-modal shadow-sm';
|
||||
|
||||
let itemsHtml = '';
|
||||
(cat.items || []).forEach((item, iIdx) => {
|
||||
itemsHtml += `
|
||||
<div class="item-row-modal">
|
||||
<div class="form-row align-items-center">
|
||||
<div class="col-md-2 mb-1">
|
||||
<label class="small font-weight-bold text-muted m-0">Mã kỹ thuật</label>
|
||||
<input type="text" class="form-control form-control-sm" value="${escapeAttr(item.code || '')}" onchange="updateModalItem(${cIdx}, ${iIdx}, 'code', this.value)" placeholder="VD: KB01" />
|
||||
</div>
|
||||
<div class="col-md-4 mb-1">
|
||||
<label class="small font-weight-bold text-muted m-0">Tên dịch vụ kỹ thuật</label>
|
||||
<input type="text" class="form-control form-control-sm" value="${escapeAttr(item.name || '')}" onchange="updateModalItem(${cIdx}, ${iIdx}, 'name', this.value)" placeholder="Tên dịch vụ..." />
|
||||
</div>
|
||||
<div class="col-md-2 mb-1">
|
||||
<label class="small font-weight-bold text-muted m-0">Mức thu (Giá)</label>
|
||||
<input type="text" class="form-control form-control-sm" value="${escapeAttr(item.unitPrice || '')}" onchange="updateModalItem(${cIdx}, ${iIdx}, 'unitPrice', this.value)" placeholder="VD: 150.000 đ" />
|
||||
</div>
|
||||
<div class="col-md-2 mb-1">
|
||||
<label class="small font-weight-bold text-muted m-0">Mức BHYT thanh toán</label>
|
||||
<input type="text" class="form-control form-control-sm" value="${escapeAttr(item.bhytPrice || '')}" onchange="updateModalItem(${cIdx}, ${iIdx}, 'bhytPrice', this.value)" placeholder="VD: 38.700 đ" />
|
||||
</div>
|
||||
<div class="col-md-2 mb-1 text-right mt-3">
|
||||
<button type="button" class="btn btn-outline-danger btn-sm" onclick="removeModalItem(${cIdx}, ${iIdx})">
|
||||
<i class="fas fa-times"></i> Xóa
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="d-flex justify-content-between align-items-center border-bottom pb-2 mb-3">
|
||||
<div class="form-inline" style="flex: 1;">
|
||||
<span class="font-weight-bold text-primary mr-2">Nhóm ${cIdx + 1}:</span>
|
||||
<input type="text" class="form-control form-control-sm font-weight-bold" style="width: 70%;" value="${escapeAttr(cat.categoryName || '')}" onchange="updateModalCatName(${cIdx}, this.value)" placeholder="Tên nhóm (VD: KHÁM BỆNH)..." />
|
||||
</div>
|
||||
<button type="button" class="btn btn-danger btn-sm" onclick="removeModalCategory(${cIdx})">
|
||||
<i class="fas fa-trash-alt"></i> Xóa Nhóm
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
${itemsHtml}
|
||||
<button type="button" class="btn btn-outline-success btn-sm mt-2" onclick="addModalItem(${cIdx})">
|
||||
<i class="fas fa-plus-circle"></i> + Thêm Dịch Vụ Vào Nhóm
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
container.appendChild(card);
|
||||
});
|
||||
}
|
||||
|
||||
function addCategoryInModal() {
|
||||
currentModalCategories.push({ categoryName: 'NHÓM DỊCH VỤ MỚI', items: [] });
|
||||
renderModalCategories();
|
||||
}
|
||||
|
||||
function removeModalCategory(cIdx) {
|
||||
if (confirm('Bạn có chắc muốn xóa nhóm này?')) {
|
||||
currentModalCategories.splice(cIdx, 1);
|
||||
renderModalCategories();
|
||||
}
|
||||
}
|
||||
|
||||
function updateModalCatName(cIdx, val) {
|
||||
currentModalCategories[cIdx].categoryName = val;
|
||||
}
|
||||
|
||||
function addModalItem(cIdx) {
|
||||
if (!currentModalCategories[cIdx].items) currentModalCategories[cIdx].items = [];
|
||||
currentModalCategories[cIdx].items.push({ code: '', name: '', unitPrice: '', bhytPrice: '', note: '' });
|
||||
renderModalCategories();
|
||||
}
|
||||
|
||||
function removeModalItem(cIdx, iIdx) {
|
||||
currentModalCategories[cIdx].items.splice(iIdx, 1);
|
||||
renderModalCategories();
|
||||
}
|
||||
|
||||
function updateModalItem(cIdx, iIdx, field, val) {
|
||||
currentModalCategories[cIdx].items[iIdx][field] = val;
|
||||
}
|
||||
|
||||
function saveModalChanges() {
|
||||
const idx = parseInt(document.getElementById('modalTableIndex').value, 10);
|
||||
const id = document.getElementById('modalTableId').value.trim() || 'table_' + Date.now();
|
||||
const name = document.getElementById('modalTableName').value.trim() || 'Bảng giá';
|
||||
const tab1Name = document.getElementById('modalTab1Name').value.trim() || 'Bảng giá';
|
||||
const tab2Name = document.getElementById('modalTab2Name').value.trim() || 'Danh mục kỹ thuật';
|
||||
const pdfUrl = document.getElementById('modalPdfUrl').value.trim();
|
||||
|
||||
const tableObj = {
|
||||
id: id,
|
||||
name: name,
|
||||
tab1Name: tab1Name,
|
||||
tab2Name: tab2Name,
|
||||
pdfUrl: pdfUrl,
|
||||
categories: currentModalCategories,
|
||||
};
|
||||
|
||||
if (idx >= 0 && idx < tables.length) {
|
||||
tables[idx] = tableObj;
|
||||
} else {
|
||||
tables.push(tableObj);
|
||||
}
|
||||
|
||||
renderMainTablesList();
|
||||
closeModal();
|
||||
}
|
||||
|
||||
function deleteTable(idx) {
|
||||
if (confirm('Bạn có chắc chắn muốn xóa bảng giá này?')) {
|
||||
tables.splice(idx, 1);
|
||||
renderMainTablesList();
|
||||
}
|
||||
}
|
||||
|
||||
function saveAllForms() {
|
||||
document.getElementById('tableData').value = JSON.stringify(tables);
|
||||
return true;
|
||||
}
|
||||
|
||||
function copyText(str) {
|
||||
navigator.clipboard.writeText(str).then(() => {
|
||||
alert('Đã copy shortcode: ' + str);
|
||||
});
|
||||
}
|
||||
|
||||
function escapeAttr(str) {
|
||||
if (!str) return '';
|
||||
return str.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
renderMainTablesList();
|
||||
});
|
||||
</script>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,436 @@
|
||||
<!DOCTYPE html>
|
||||
<html xmlns:th="http://www.thymeleaf.org"
|
||||
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
|
||||
layout:decorate="~{fragments/manage-layout}">
|
||||
<head>
|
||||
<title>Quản lý Bảng Tuyển dụng Nhân sự</title>
|
||||
</head>
|
||||
<body>
|
||||
<div layout:fragment="content">
|
||||
<div class="d-sm-flex align-items-center justify-content-between mb-4">
|
||||
<h1 class="h3 mb-0 text-gray-800">
|
||||
<i class="fas fa-user-tie text-primary mr-2"></i>Quản lý Bảng Tuyển dụng Nhân sự
|
||||
</h1>
|
||||
<button type="button" class="btn btn-sm btn-success shadow-sm" id="btnAddTable">
|
||||
<i class="fas fa-plus mr-1"></i>Thêm Bảng Tuyển dụng Mới
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Flash Messages -->
|
||||
<div th:if="${successMessage}" class="alert alert-success alert-dismissible fade show shadow-sm" role="alert">
|
||||
<i class="fas fa-check-circle mr-2"></i><span th:text="${successMessage}"></span>
|
||||
<button type="button" class="close" data-dismiss="alert" aria-label="Close">
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
<div th:if="${errorMessage}" class="alert alert-danger alert-dismissible fade show shadow-sm" role="alert">
|
||||
<i class="fas fa-exclamation-triangle mr-2"></i><span th:text="${errorMessage}"></span>
|
||||
<button type="button" class="close" data-dismiss="alert" aria-label="Close">
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Guide Card -->
|
||||
<div class="card border-left-primary shadow mb-4">
|
||||
<div class="card-body">
|
||||
<div class="row align-items-center">
|
||||
<div class="col">
|
||||
<h6 class="font-weight-bold text-primary mb-1"><i class="fas fa-info-circle mr-1"></i> Hướng dẫn chèn Shortcode:</h6>
|
||||
<p class="small text-muted mb-0">
|
||||
Chèn vào bất kỳ trang hoặc bài viết bằng mã Shortcode:
|
||||
<code class="bg-light px-2 py-1 rounded text-primary font-weight-bold">[plugin:recruiting-table]</code> (Bảng mặc định)
|
||||
hoặc <code class="bg-light px-2 py-1 rounded text-primary font-weight-bold">[plugin:recruiting-table id="ID_BANG"]</code> (VD: <code>[plugin:recruiting-table id="recruiting_1"]</code>).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Form -->
|
||||
<form id="recruitingForm" th:action="@{/manage/plugins/recruiting-table}" method="post">
|
||||
<input type="hidden" name="tableData" id="tableDataInput"/>
|
||||
|
||||
<div id="tablesContainer">
|
||||
<!-- Dynamic Tables Rendered Here by JS -->
|
||||
</div>
|
||||
|
||||
<div class="card shadow mb-4">
|
||||
<div class="card-body text-right">
|
||||
<button type="button" class="btn btn-secondary mr-2" id="btnAddTable2">
|
||||
<i class="fas fa-plus mr-1"></i>Thêm Bảng Mới
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary px-4 shadow-sm" id="btnSaveAll">
|
||||
<i class="fas fa-save mr-1"></i>Lưu Tất Cả Cấu Hình
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<!-- Item Edit Modal -->
|
||||
<div class="modal fade" id="itemModal" tabindex="-1" role="dialog" aria-hidden="true">
|
||||
<div class="modal-dialog" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title font-weight-bold text-primary" id="itemModalLabel">Cập nhật Vị trí Tuyển dụng</h5>
|
||||
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<input type="hidden" id="modal-tidx">
|
||||
<input type="hidden" id="modal-iidx">
|
||||
<div class="form-group">
|
||||
<label class="small font-weight-bold">Vị trí tuyển dụng</label>
|
||||
<input type="text" class="form-control" id="modal-pos" placeholder="VD: Bác sĩ Cấp cứu">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="small font-weight-bold">Số lượng</label>
|
||||
<input type="text" class="form-control" id="modal-qty" placeholder="VD: 01">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="small font-weight-bold">Thời hạn (Hạn nộp)</label>
|
||||
<input type="text" class="form-control" id="modal-dl" placeholder="VD: 31/08/2026">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="small font-weight-bold">Link Mô tả công việc (URL)</label>
|
||||
<input type="text" class="form-control" id="modal-url" placeholder="VD: /tuyen-dung/bac-si-cap-cuu">
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-dismiss="modal">Hủy bỏ</button>
|
||||
<button type="button" class="btn btn-primary" id="btnSaveItem">Lưu thay đổi</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Table Edit Modal -->
|
||||
<div class="modal fade" id="tableModal" tabindex="-1" role="dialog" aria-hidden="true">
|
||||
<div class="modal-dialog" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title font-weight-bold text-primary">Cập nhật Thông tin Bảng</h5>
|
||||
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<input type="hidden" id="modal-table-tidx">
|
||||
<div class="form-group">
|
||||
<label class="small font-weight-bold">Tên Bảng Tuyển dụng</label>
|
||||
<input type="text" class="form-control" id="modal-table-name" placeholder="VD: Tuyển dụng Bác sĩ">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="small font-weight-bold">Mã ID Bảng (dùng trong Shortcode)</label>
|
||||
<input type="text" class="form-control" id="modal-table-id" placeholder="VD: recruiting_1">
|
||||
<small class="form-text text-muted">Phải là chuỗi không dấu, không khoảng trắng (vd: bang_1).</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="small font-weight-bold">Ảnh Đại Diện Bảng (tùy chọn)</label>
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control" id="modal-table-image" placeholder="URL Hình ảnh (hoặc Chọn từ Thư viện)">
|
||||
<div class="input-group-append">
|
||||
<button class="btn btn-outline-secondary" type="button" id="btnBrowseTableImage">
|
||||
<i class="fas fa-image"></i> Chọn Ảnh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2 text-center" id="preview-table-image-container" style="display: none;">
|
||||
<img id="preview-table-image" src="" alt="Preview" style="max-height: 120px; border-radius: 4px; border: 1px solid #ddd; object-fit: cover; max-width: 100%;" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-dismiss="modal">Hủy bỏ</button>
|
||||
<button type="button" class="btn btn-primary" id="btnSaveTable">Lưu thay đổi</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<th:block layout:fragment="scripts">
|
||||
<script th:src="@{/js/manage/sis-media-picker.js}"></script>
|
||||
<script th:inline="javascript">
|
||||
/*<![CDATA[*/
|
||||
var initialTables = /*[[${tablesJson}]]*/ '[]';
|
||||
/*]]>*/
|
||||
|
||||
$(document).ready(function() {
|
||||
var tablesData = [];
|
||||
try {
|
||||
tablesData = typeof initialTables === 'string' ? JSON.parse(initialTables) : initialTables;
|
||||
} catch(e) {
|
||||
tablesData = [];
|
||||
}
|
||||
|
||||
if (!Array.isArray(tablesData) || tablesData.length === 0) {
|
||||
tablesData = [{
|
||||
id: 'recruiting_1',
|
||||
name: 'Danh sách vị trí tuyển dụng Bác sĩ',
|
||||
description: 'Nhu cầu tuyển dụng các vị trí Bác sĩ',
|
||||
items: [
|
||||
{ position: 'Bác sĩ Cấp cứu', quantity: '01', deadline: '31/08/2026', detailUrl: '#' },
|
||||
{ position: 'Bác sĩ Huyết học', quantity: '01', deadline: '30/06/2026', detailUrl: '#' },
|
||||
{ position: 'Bác sĩ Xét nghiệm', quantity: '01', deadline: '30/06/2026', detailUrl: '#' },
|
||||
{ position: 'Bác sĩ Vi sinh', quantity: '01', deadline: '30/06/2026', detailUrl: '#' }
|
||||
]
|
||||
}];
|
||||
}
|
||||
|
||||
function renderTables() {
|
||||
var $container = $('#tablesContainer').empty();
|
||||
if (tablesData.length === 0) {
|
||||
$container.html('<div class="alert alert-light text-center py-5 border rounded"><i class="fas fa-folder-open fa-2x text-muted mb-2"></i><br/>Chưa có bảng tuyển dụng nào. Nhấn "Thêm Bảng Mới" để khởi tạo.</div>');
|
||||
return;
|
||||
}
|
||||
|
||||
tablesData.forEach(function(table, tIdx) {
|
||||
var cardHtml = `
|
||||
<div class="card shadow mb-4 table-card" data-tidx="${tIdx}">
|
||||
<div class="card-header py-3 d-flex flex-row align-items-center justify-content-between bg-light">
|
||||
<h6 class="m-0 font-weight-bold text-primary mr-2" style="font-size: 1.1rem;">
|
||||
<i class="fas fa-table mr-2"></i><span class="table-title-display">${escapeHtml(table.name || 'Bảng chưa có tên')}</span>
|
||||
</h6>
|
||||
<div>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary btn-edit-table" data-tidx="${tIdx}" title="Sửa thông tin Bảng">
|
||||
<i class="fas fa-cog mr-1"></i>Sửa Thông Tin Bảng
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-danger btn-delete-table ml-2" data-tidx="${tIdx}" title="Xóa Bảng Này">
|
||||
<i class="fas fa-trash-alt mr-1"></i>Xóa Bảng Này
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-3 d-flex align-items-center">
|
||||
<div class="mr-3">
|
||||
<strong>Mã ID:</strong> <span class="text-danger">${escapeHtml(table.id || '')}</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Shortcode:</strong> <code class="bg-light px-2 py-1 rounded">[plugin:recruiting-table id="${escapeHtml(table.id || '')}"]</code>
|
||||
</div>
|
||||
</div>
|
||||
${table.imageUrl ? `
|
||||
<div class="mb-3 text-center">
|
||||
<img src="${escapeHtml(table.imageUrl)}" style="max-height: 80px; border-radius: 4px; border: 1px solid #eee;" alt="Ảnh Bảng" />
|
||||
</div>` : ''}
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table table-bordered table-sm items-table mb-2">
|
||||
<thead class="bg-light">
|
||||
<tr class="text-center small text-muted">
|
||||
<th style="width: 35%;">Vị trí tuyển dụng</th>
|
||||
<th style="width: 15%;">Số lượng</th>
|
||||
<th style="width: 20%;">Thời hạn (Hạn nộp)</th>
|
||||
<th style="width: 20%;">Link Chi tiết (URL)</th>
|
||||
<th style="width: 10%;">Thao tác</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
`;
|
||||
|
||||
var items = table.items || [];
|
||||
if (items.length === 0) {
|
||||
cardHtml += `<tr><td colspan="5" class="text-center text-muted small py-3">Chưa có vị trí tuyển dụng nào trong bảng này.</td></tr>`;
|
||||
} else {
|
||||
items.forEach(function(item, iIdx) {
|
||||
cardHtml += `
|
||||
<tr data-iidx="${iIdx}">
|
||||
<td class="align-middle">${escapeHtml(item.position || '')}</td>
|
||||
<td class="align-middle text-center">${escapeHtml(item.quantity || '')}</td>
|
||||
<td class="align-middle text-center">${escapeHtml(item.deadline || '')}</td>
|
||||
<td class="align-middle">
|
||||
<a href="${escapeHtml(item.detailUrl || '#')}" target="_blank" class="text-truncate d-inline-block" style="max-width: 150px;" title="${escapeHtml(item.detailUrl || '')}">
|
||||
${escapeHtml(item.detailUrl || '')}
|
||||
</a>
|
||||
</td>
|
||||
<td class="text-center align-middle">
|
||||
<button type="button" class="btn btn-xs btn-outline-primary btn-edit-item mr-1" title="Sửa vị trí này">
|
||||
<i class="fas fa-edit"></i>
|
||||
</button>
|
||||
<button type="button" class="btn btn-xs btn-outline-danger btn-delete-item" title="Xóa vị trí này">
|
||||
<i class="fas fa-trash-alt"></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
});
|
||||
}
|
||||
|
||||
cardHtml += `
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<button type="button" class="btn btn-xs btn-outline-success btn-add-item mt-1" data-tidx="${tIdx}">
|
||||
<i class="fas fa-plus mr-1"></i>Thêm Vị Trí Tuyển Dụng
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
$container.append(cardHtml);
|
||||
});
|
||||
}
|
||||
|
||||
renderTables();
|
||||
|
||||
// Event: Add table
|
||||
$('#btnAddTable, #btnAddTable2').click(function() {
|
||||
var newId = 'recruiting_' + (tablesData.length + 1);
|
||||
tablesData.push({
|
||||
id: newId,
|
||||
name: 'Bảng tuyển dụng mới ' + (tablesData.length + 1),
|
||||
description: '',
|
||||
imageUrl: '',
|
||||
items: []
|
||||
});
|
||||
renderTables();
|
||||
});
|
||||
|
||||
// Event: Show Edit Table Modal
|
||||
$(document).on('click', '.btn-edit-table', function() {
|
||||
var tIdx = $(this).data('tidx');
|
||||
var table = tablesData[tIdx];
|
||||
|
||||
$('#modal-table-tidx').val(tIdx);
|
||||
$('#modal-table-id').val(table.id || '');
|
||||
$('#modal-table-name').val(table.name || '');
|
||||
$('#modal-table-image').val(table.imageUrl || '');
|
||||
|
||||
if (table.imageUrl) {
|
||||
$('#preview-table-image').attr('src', table.imageUrl);
|
||||
$('#preview-table-image-container').show();
|
||||
} else {
|
||||
$('#preview-table-image-container').hide();
|
||||
$('#preview-table-image').attr('src', '');
|
||||
}
|
||||
|
||||
$('#tableModal').modal('show');
|
||||
});
|
||||
|
||||
// Event: Save Table from Modal
|
||||
$('#btnSaveTable').click(function() {
|
||||
var tIdx = parseInt($('#modal-table-tidx').val(), 10);
|
||||
var tId = $('#modal-table-id').val().trim();
|
||||
var tName = $('#modal-table-name').val().trim();
|
||||
var tImage = $('#modal-table-image').val().trim();
|
||||
|
||||
tablesData[tIdx].id = tId;
|
||||
tablesData[tIdx].name = tName;
|
||||
tablesData[tIdx].imageUrl = tImage;
|
||||
|
||||
renderTables();
|
||||
$('#tableModal').modal('hide');
|
||||
});
|
||||
|
||||
// Event: Browse Table Image with SISMediaPicker
|
||||
$('#btnBrowseTableImage').click(function() {
|
||||
if (typeof SISMediaPicker !== 'undefined') {
|
||||
SISMediaPicker.open(function(url) {
|
||||
$('#modal-table-image').val(url);
|
||||
$('#preview-table-image').attr('src', url);
|
||||
$('#preview-table-image-container').show();
|
||||
});
|
||||
} else {
|
||||
alert('SISMediaPicker chưa được tải!');
|
||||
}
|
||||
});
|
||||
|
||||
// Event: Delete table
|
||||
$(document).on('click', '.btn-delete-table', function() {
|
||||
var tIdx = $(this).data('tidx');
|
||||
if (confirm('Bạn có chắc chắn muốn xóa toàn bộ bảng tuyển dụng này không?')) {
|
||||
tablesData.splice(tIdx, 1);
|
||||
renderTables();
|
||||
}
|
||||
});
|
||||
|
||||
// Event: Show Add Item Modal
|
||||
$(document).on('click', '.btn-add-item', function() {
|
||||
var tIdx = $(this).data('tidx');
|
||||
$('#modal-tidx').val(tIdx);
|
||||
$('#modal-iidx').val('-1'); // -1 means new item
|
||||
|
||||
$('#modal-pos').val('');
|
||||
$('#modal-qty').val('01');
|
||||
$('#modal-dl').val('');
|
||||
$('#modal-url').val('#');
|
||||
|
||||
$('#itemModalLabel').text('Thêm Vị trí Tuyển dụng mới');
|
||||
$('#itemModal').modal('show');
|
||||
});
|
||||
|
||||
// Event: Show Edit Item Modal
|
||||
$(document).on('click', '.btn-edit-item', function() {
|
||||
var $tr = $(this).closest('tr');
|
||||
var $card = $(this).closest('.table-card');
|
||||
var tIdx = $card.data('tidx');
|
||||
var iIdx = $tr.data('iidx');
|
||||
|
||||
var item = tablesData[tIdx].items[iIdx];
|
||||
|
||||
$('#modal-tidx').val(tIdx);
|
||||
$('#modal-iidx').val(iIdx);
|
||||
|
||||
$('#modal-pos').val(item.position || '');
|
||||
$('#modal-qty').val(item.quantity || '');
|
||||
$('#modal-dl').val(item.deadline || '');
|
||||
$('#modal-url').val(item.detailUrl || '');
|
||||
|
||||
$('#itemModalLabel').text('Cập nhật Vị trí Tuyển dụng');
|
||||
$('#itemModal').modal('show');
|
||||
});
|
||||
|
||||
// Event: Save Item from Modal
|
||||
$('#btnSaveItem').click(function() {
|
||||
var tIdx = parseInt($('#modal-tidx').val(), 10);
|
||||
var iIdx = parseInt($('#modal-iidx').val(), 10);
|
||||
|
||||
var newItem = {
|
||||
position: $('#modal-pos').val().trim(),
|
||||
quantity: $('#modal-qty').val().trim(),
|
||||
deadline: $('#modal-dl').val().trim(),
|
||||
detailUrl: $('#modal-url').val().trim()
|
||||
};
|
||||
|
||||
if (iIdx === -1) {
|
||||
// Add new
|
||||
if (!tablesData[tIdx].items) tablesData[tIdx].items = [];
|
||||
tablesData[tIdx].items.push(newItem);
|
||||
} else {
|
||||
// Update existing
|
||||
tablesData[tIdx].items[iIdx] = newItem;
|
||||
}
|
||||
|
||||
$('#itemModal').modal('hide');
|
||||
renderTables();
|
||||
});
|
||||
|
||||
// Event: Delete item row
|
||||
$(document).on('click', '.btn-delete-item', function() {
|
||||
var $tr = $(this).closest('tr');
|
||||
var $card = $(this).closest('.table-card');
|
||||
var tIdx = $card.data('tidx');
|
||||
var iIdx = $tr.data('iidx');
|
||||
|
||||
if (confirm('Xóa vị trí tuyển dụng này?')) {
|
||||
tablesData[tIdx].items.splice(iIdx, 1);
|
||||
renderTables();
|
||||
}
|
||||
});
|
||||
|
||||
// Form Submit
|
||||
$('#recruitingForm').submit(function() {
|
||||
$('#tableDataInput').val(JSON.stringify(tablesData));
|
||||
return true;
|
||||
});
|
||||
|
||||
function escapeHtml(str) {
|
||||
if (str === null || str === undefined) return '';
|
||||
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</th:block>
|
||||
</body>
|
||||
</html>
|
||||
@@ -138,6 +138,7 @@
|
||||
|
||||
/* Mega Menu Layout columns */
|
||||
.mega-menu-content {
|
||||
min-height: 250px;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
max-width: 1200px;
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
<!doctype html>
|
||||
<html lang="vi">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Cơ Hội Nghề Nghiệp - S.I.S</title>
|
||||
<style>
|
||||
/* ===================================================
|
||||
1. RESET & VARIABLES
|
||||
=================================================== */
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: Roboto, ui-sans-serif, system-ui, sans-serif;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: #f8fafc;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* ===================================================
|
||||
2. HERO SECTION LAYOUT
|
||||
=================================================== */
|
||||
.career-hero {
|
||||
position: relative;
|
||||
padding: 40px 0;
|
||||
min-height: 500px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Lớp phủ Background Gradient */
|
||||
.hero-bg-1 {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(360deg, #f0faff 65.38%, rgba(253, 254, 255, 0) 100%);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.hero-bg-2 {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(360deg, #b7eaff 29.33%, rgba(253, 254, 255, 0) 100%);
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
/* Container chứa nội dung chính */
|
||||
.hero-container {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 0 40px;
|
||||
}
|
||||
|
||||
/* Tiêu đề H1 chính */
|
||||
.hero-title {
|
||||
font-size: 42px;
|
||||
font-weight: 700;
|
||||
color: #0d71ba; /* Màu xanh y tế chuẩn */
|
||||
line-height: 1.2;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
/* Khung chứa hình chứng nhận và các danh hiệu */
|
||||
.awards-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
/* Hình Bằng khen / Chứng nhận */
|
||||
.certificate-box {
|
||||
position: relative;
|
||||
width: 313px;
|
||||
aspect-ratio: 2 / 3;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.certificate-box img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
/* Danh sách giải thưởng */
|
||||
.awards-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 32px;
|
||||
}
|
||||
|
||||
.award-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.award-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.trophy-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.award-badge {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: #0d68aa;
|
||||
border-bottom: 2px solid #0d68aa;
|
||||
display: inline-block;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.award-desc {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #0d68aa;
|
||||
line-height: 1.4;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* Hình ảnh Bác sĩ bên phải */
|
||||
.doctor-image-box {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 32px;
|
||||
width: 630px;
|
||||
aspect-ratio: 630 / 480;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.doctor-image-box img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
/* ===================================================
|
||||
3. RESPONSIVE DESIGN (Cho Mobile & Tablet)
|
||||
=================================================== */
|
||||
@media (max-width: 1200px) {
|
||||
.hero-title {
|
||||
font-size: 34px;
|
||||
}
|
||||
.certificate-box {
|
||||
width: 220px;
|
||||
}
|
||||
.doctor-image-box {
|
||||
width: 450px;
|
||||
}
|
||||
.award-badge {
|
||||
font-size: 20px;
|
||||
}
|
||||
.award-desc {
|
||||
font-size: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 992px) {
|
||||
.doctor-image-box {
|
||||
width: 350px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.hero-container {
|
||||
padding: 0 20px;
|
||||
}
|
||||
.hero-title {
|
||||
font-size: 28px;
|
||||
}
|
||||
/* Ẩn hình ảnh bác sĩ lớn trên điện thoại */
|
||||
.doctor-image-box {
|
||||
display: none;
|
||||
}
|
||||
.certificate-box {
|
||||
width: 140px;
|
||||
}
|
||||
.trophy-icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
.award-badge {
|
||||
font-size: 16px;
|
||||
}
|
||||
.award-desc {
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<section class="career-hero">
|
||||
<!-- Lớp phủ Gradient mờ -->
|
||||
<div class="hero-bg-1"></div>
|
||||
<div class="hero-bg-2"></div>
|
||||
|
||||
<!-- Nội dung chính Banner -->
|
||||
<div class="hero-container">
|
||||
<!-- Tiêu đề -->
|
||||
<h1 class="hero-title">Cơ hội nghề nghiệp</h1>
|
||||
|
||||
<!-- Cột Nội dung Bằng khen & Cúp danh hiệu -->
|
||||
<div class="awards-wrapper">
|
||||
<!-- Ảnh Chứng nhận/Bằng khen bên trái -->
|
||||
<div class="certificate-box">
|
||||
<img src="/_next/image?url=%2Fimages%2Fcareer%2Fcertificate.webp&w=1200&q=80" alt="Bằng khen S.I.S" />
|
||||
</div>
|
||||
|
||||
<!-- Khối danh hiệu Top 10 & Top 50 -->
|
||||
<div class="awards-list">
|
||||
<!-- TOP 10 -->
|
||||
<div class="award-item">
|
||||
<div class="award-header">
|
||||
<img src="/_next/image?url=%2Fimages%2Fcareer%2Ftrophy.png&w=640&q=80" alt="Cúp danh hiệu" class="trophy-icon" />
|
||||
<span class="award-badge">TOP 10</span>
|
||||
</div>
|
||||
<p class="award-desc">NƠI LÀM VIỆC TỐT NHẤT NGÀNH Y DƯỢC - CHĂM SÓC SỨC KHOẺ</p>
|
||||
</div>
|
||||
|
||||
<!-- TOP 50 -->
|
||||
<div class="award-item">
|
||||
<div class="award-header">
|
||||
<img src="/_next/image?url=%2Fimages%2Fcareer%2Ftrophy.png&w=640&q=80" alt="Cúp danh hiệu" class="trophy-icon" />
|
||||
<span class="award-badge">TOP 50</span>
|
||||
</div>
|
||||
<p class="award-desc">NƠI LÀM VIỆC TỐT NHẤT VIỆT NAM</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ảnh Bác sĩ định vị góc phải (Tự động ẩn trên Mobile) -->
|
||||
<div class="doctor-image-box">
|
||||
<img src="/_next/image?url=%2Fimages%2Fcareer%2Fhero-doctor.webp&w=1200&q=80" alt="Đội ngũ Y Bác sĩ S.I.S" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</body>
|
||||
</html>
|
||||
|
After Width: | Height: | Size: 107 KiB |
|
After Width: | Height: | Size: 196 KiB |
|
After Width: | Height: | Size: 196 KiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 262 KiB |
@@ -0,0 +1,3 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 0 1024 1024" class="icon" version="1.1" xmlns="http://www.w3.org/2000/svg"><path d="M512 301.2m-10 0a10 10 0 1 0 20 0 10 10 0 1 0-20 0Z" fill="#E73B37" /><path d="M400.3 744.5c2.1-0.7 4.1-1.4 6.2-2-2 0.6-4.1 1.3-6.2 2z m0 0c2.1-0.7 4.1-1.4 6.2-2-2 0.6-4.1 1.3-6.2 2z" fill="#39393A" /><path d="M511.8 256.6c24.4 0 44.2 19.8 44.2 44.2S536.2 345 511.8 345s-44.2-19.8-44.2-44.2 19.9-44.2 44.2-44.2m0-20c-35.5 0-64.2 28.7-64.2 64.2s28.7 64.2 64.2 64.2 64.2-28.7 64.2-64.2-28.7-64.2-64.2-64.2z" fill="#E73B37" /><path d="M730.7 529.5c0.4-8.7 0.6-17.4 0.6-26.2 0-179.6-86.1-339.1-219.3-439.5-133.1 100.4-219.2 259.9-219.2 439.5 0 8.8 0.2 17.5 0.6 26.1-56 56-90.6 133.3-90.6 218.7 0 61.7 18 119.1 49.1 167.3 30.3-49.8 74.7-90.1 127.7-115.3 39-18.6 82.7-29 128.8-29 48.3 0 93.9 11.4 134.3 31.7 52.5 26.3 96.3 67.7 125.6 118.4 33.4-49.4 52.9-108.9 52.9-173.1 0-85.4-34.6-162.6-90.5-218.6zM351.1 383.4c9.2-37.9 22.9-74.7 40.6-109.5a502.1 502.1 0 0 1 63.6-95.9c17.4-20.6 36.4-39.9 56.8-57.5 20.4 17.6 39.4 36.9 56.8 57.5 24.8 29.5 46.2 61.8 63.6 95.9 17.7 34.8 31.4 71.6 40.6 109.5 8.7 35.8 13.5 72.7 14.2 109.9C637.4 459 577 438.9 512 438.9c-65 0-125.3 20.1-175.1 54.4 0.7-37.2 5.5-74.1 14.2-109.9z m-90.6 449.2c-9.1-27-13.7-55.5-13.7-84.4 0-35.8 7-70.6 20.8-103.2 8.4-19.8 19-38.4 31.9-55.5 9.7 61.5 29.5 119.7 57.8 172.6-36.4 17.8-69 41.6-96.8 70.5z m364.2-85.3c-0.7-0.3-1.5-0.5-2.2-0.8-0.4-0.2-0.9-0.3-1.3-0.5-0.6-0.2-1.3-0.5-1.9-0.7-0.8-0.3-1.5-0.5-2.3-0.8-0.8-0.3-1.5-0.5-2.3-0.7l-0.9-0.3c-1-0.3-2.1-0.7-3.1-1-1.2-0.4-2.4-0.7-3.5-1.1l-3-0.9c-0.2-0.1-0.4-0.1-0.7-0.2-1.1-0.3-2.3-0.7-3.4-1-1.2-0.3-2.4-0.6-3.5-0.9l-3.6-0.9-3.6-0.9c-1-0.3-2.1-0.5-3.1-0.7-1.2-0.3-2.4-0.5-3.6-0.8-1.3-0.3-2.5-0.6-3.8-0.8h-0.3c-0.9-0.2-1.9-0.4-2.8-0.6-0.4-0.1-0.7-0.1-1.1-0.2-1.1-0.2-2.2-0.4-3.4-0.6-1.2-0.2-2.4-0.4-3.6-0.7l-5.4-0.9c-0.9-0.1-1.9-0.3-2.8-0.4-0.8-0.1-1.6-0.3-2.5-0.4-2.6-0.4-5.1-0.7-7.7-1-1.2-0.1-2.3-0.3-3.5-0.4h-0.4c-0.9-0.1-1.8-0.2-2.8-0.3-1.1-0.1-2.1-0.2-3.2-0.3-1.7-0.2-3.4-0.3-5.1-0.4-0.8-0.1-1.5-0.1-2.3-0.2-0.9-0.1-1.9-0.1-2.8-0.2-0.4 0-0.8 0-1.2-0.1-1.1-0.1-2.1-0.1-3.2-0.2-0.5 0-1-0.1-1.5-0.1-1.3-0.1-2.6-0.1-3.9-0.1-0.8 0-1.5-0.1-2.3-0.1-1.2 0-2.4 0-3.5-0.1h-13.9c-2.3 0-4.6 0.1-6.9 0.2-0.9 0-1.9 0.1-2.8 0.1-0.8 0-1.5 0.1-2.3 0.1-1.4 0.1-2.8 0.2-4.1 0.3-1.4 0.1-2.7 0.2-4.1 0.3-1.4 0.1-2.7 0.2-4.1 0.4-0.6 0-1.2 0.1-1.8 0.2l-7.8 0.9c-1.1 0.1-2.1 0.3-3.2 0.4-1 0.1-2.1 0.3-3.1 0.4-3.2 0.5-6.4 0.9-9.5 1.5-0.7 0.1-1.4 0.2-2.1 0.4-0.9 0.1-1.7 0.3-2.6 0.5-1.1 0.2-2.3 0.4-3.4 0.6-0.9 0.2-1.7 0.3-2.6 0.5-0.4 0.1-0.8 0.1-1.1 0.2-0.7 0.1-1.4 0.3-2.1 0.4-1.2 0.3-2.4 0.5-3.6 0.8-1.2 0.3-2.4 0.5-3.6 0.8-0.2 0-0.4 0.1-0.6 0.1-0.5 0.1-1 0.2-1.5 0.4-1.1 0.3-2.3 0.6-3.5 0.9-1.3 0.3-2.5 0.6-3.8 1-0.4 0.1-0.9 0.2-1.4 0.4-1.3 0.4-2.7 0.7-4 1.1-1.5 0.4-3 0.9-4.6 1.3-1 0.3-2.1 0.6-3.1 1-2.1 0.6-4.1 1.3-6.2 2-0.7 0.2-1.4 0.5-2.1 0.7-15-27.5-27.4-56.4-37-86.2-11.7-36.1-19.2-73.6-22.5-111.6-0.6-6.7-1-13.3-1.3-20-0.1-1.2-0.1-2.4-0.1-3.6-0.1-1.2-0.1-2.4-0.1-3.6 0-1.2-0.1-2.4-0.1-3.6 0-1.2-0.1-2.4-0.1-3.7 18.8-14 39.2-25.8 61-35 36.1-15.3 74.5-23 114.1-23 39.6 0 78 7.8 114.1 23 21.8 9.2 42.2 20.9 61 35v0.1c0 1 0 1.9-0.1 2.9 0 1.4-0.1 2.8-0.1 4.3 0 0.7 0 1.3-0.1 2-0.1 1.8-0.1 3.5-0.2 5.3-0.3 6.7-0.8 13.3-1.3 20-3.3 38.5-11 76.5-23 113-9.7 30.3-22.3 59.4-37.6 87.1z m136.8 90.9a342.27 342.27 0 0 0-96.3-73.2c29.1-53.7 49.5-112.8 59.4-175.5 12.8 17.1 23.4 35.6 31.8 55.5 13.8 32.7 20.8 67.4 20.8 103.2 0 31-5.3 61.3-15.7 90z" fill="#39393A" /><path d="M512 819.3c8.7 0 24.7 22.9 24.7 60.4s-16 60.4-24.7 60.4-24.7-22.9-24.7-60.4 16-60.4 24.7-60.4m0-20c-24.7 0-44.7 36-44.7 80.4 0 44.4 20 80.4 44.7 80.4s44.7-36 44.7-80.4c0-44.4-20-80.4-44.7-80.4z" fill="#E73B37" /></svg>
|
||||
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 28 KiB |
@@ -0,0 +1,3 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 0 1024 1024" class="icon" version="1.1" xmlns="http://www.w3.org/2000/svg"><path d="M429.9 186.7v406.4h407.5c-4 34.1-12.8 67.3-26.2 99.1-18.4 43.6-44.8 82.7-78.5 116.3-33.6 33.6-72.8 60-116.4 78.4-45.1 19.1-93 28.7-142.5 28.7-49.4 0-97.4-9.7-142.5-28.7-43.6-18.4-82.7-44.8-116.4-78.4-33.6-33.6-60-72.7-78.4-116.3-19.1-45.1-28.7-93-28.7-142.4s9.7-97.3 28.7-142.4c18.4-43.6 44.8-82.7 78.4-116.3 33.6-33.6 72.8-60 116.4-78.4 31.7-13.2 64.7-21.9 98.6-26m44-46.6c-226.4 0-410 183.5-410 409.8s183.6 409.8 410 409.8 410-183.5 410-409.8v-0.8h-410v-409z" fill="#39393A" /><path d="M566.1 80.5c43.7 1.7 86.4 10.6 127 26.4 44 17.1 84.2 41.8 119.6 73.5 71.7 64.1 117.4 151.7 128.7 246.7 1.2 9.9 2 20 2.4 30.2H566.1V80.5m-16-16.3v409h410c0-16.3-1-32.3-2.9-48.1C933.1 221.9 760 64.2 550.1 64.2zM264.7 770.4c-23.1-23.1-42.3-49.1-57.3-77.7l-14.7 6.5c35.7 68.2 94 122.7 165 153.5l4.3-15.6c-36.3-16-69.1-38.4-97.3-66.7z" fill="#E73B37" /></svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,3 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 0 1024 1024" class="icon" version="1.1" xmlns="http://www.w3.org/2000/svg"><path d="M828.5 180.1h-9.9v-54.7h23.5v-44H182v44h23v54.7h-9.5C123.2 180.1 64 239.2 64 311.5v0.1c0 72.3 59.2 131.5 131.5 131.5h9.6c0 1.3 0.1 2.5 0.1 3.7 0.5 17.7 2.7 35.4 6.2 52.5 17.8 85.7 71.8 160 148.3 204 4.8 2.8 9.8 5.4 14.7 7.9 15.3 7.7 31.2 14.1 47.4 19.2 3.4 1 6.8 2 10.2 2.9v165.2H250.4v44h511.9v-44H591.9V733.4c3.7-1 7.3-2.1 10.9-3.2 16.2-5.1 32.2-11.6 47.4-19.4 5-2.5 10-5.3 14.8-8.1 75.6-43.9 129.2-117.8 147-202.7 3.6-17.2 5.8-34.9 6.3-52.4 0.1-1.5 0.1-3 0.1-4.5h10c72.3 0 131.5-59.2 131.5-131.5v-0.1c0.1-72.3-59.1-131.4-131.4-131.4zM205 399.2h-9.5c-23.2 0-45.1-9.1-61.7-25.7s-25.7-38.5-25.7-61.7v-0.1c0-23.2 9.1-45.2 25.7-61.7 16.6-16.6 38.5-25.7 61.7-25.7h9.5v174.9z m370.9 499.4h-128V737.3c20.9 4.5 42.3 6.8 63.9 6.8 21.7 0 43.1-2.3 64.1-6.8v161.3z m198.7-461.4c0 2.9 0 5.9-0.2 8.9-0.5 15-2.3 30.1-5.4 44.9-15.3 72.7-61.2 136-126.1 173.7-4.1 2.4-8.4 4.7-12.7 6.9-13 6.6-26.7 12.2-40.6 16.6-25.2 7.9-51.4 11.9-77.9 11.9-26.2 0-52.2-3.9-77.1-11.6-13.9-4.3-27.5-9.8-40.6-16.4-4.2-2.1-8.5-4.4-12.6-6.8-65.4-37.8-111.7-101.5-126.9-174.8-3.1-14.7-4.9-29.8-5.3-45-0.1-2.7-0.1-5.5-0.1-8.2v-312h525.6v311.9zM916 311.7c0 23.2-9.1 45.2-25.7 61.7-16.6 16.6-38.5 25.7-61.7 25.7h-9.9v-175h9.9c23.2 0 45.1 9.1 61.7 25.7s25.7 38.5 25.7 61.7v0.2z" fill="#39393A" /><path d="M317.428 274.917l70.145-70.144 14.142 14.142-70.145 70.144zM316.055 351.98L456.13 211.904l14.142 14.142-140.076 140.076zM555.4 659.6l-4.8-19.4c0.3-0.1 26.5-6.8 55.4-23.5 37.8-21.9 62-49.7 72-82.7l19.1 5.8c-11.4 37.6-39.6 70.3-81.6 94.5-31.2 18-58.9 25-60.1 25.3z" fill="#E73B37" /></svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |