feat: integrate Swiper.js, update media persistence, and enhance page metadata support
@@ -1,15 +1,6 @@
|
|||||||
{
|
{
|
||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"configurations": [
|
"configurations": [
|
||||||
{
|
|
||||||
"type": "java",
|
|
||||||
"name": "Debug (Launch) - SisvietnamvnApp",
|
|
||||||
"request": "launch",
|
|
||||||
"mainClass": "com.sisvietnamvn.web.SisvietnamvnApp",
|
|
||||||
"projectName": "sisvietnamvn",
|
|
||||||
"cwd": "${workspaceFolder}/sisvietnamvn_main",
|
|
||||||
"vmArgs": "-Dspring.profiles.active=dev"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"type": "java",
|
"type": "java",
|
||||||
"name": "Debug (Attach) - Port 5005",
|
"name": "Debug (Attach) - Port 5005",
|
||||||
|
|||||||
@@ -127,3 +127,36 @@ Tôi đã chạy lệnh ./gradlew spotlessApply để tự động dọn dẹp v
|
|||||||
(Lưu ý: Có một lỗi riêng trong lệnh chạy test ./gradlew test liên quan đến cấu hình taskExecutor bị thiếu của dự án gốc đối với môi trường test, nhưng nó hoàn toàn không ảnh hưởng đến việc chạy ứng dụng thực tế bằng ./gradlew bootRun).
|
(Lưu ý: Có một lỗi riêng trong lệnh chạy test ./gradlew test liên quan đến cấu hình taskExecutor bị thiếu của dự án gốc đối với môi trường test, nhưng nó hoàn toàn không ảnh hưởng đến việc chạy ứng dụng thực tế bằng ./gradlew bootRun).
|
||||||
|
|
||||||
Bạn có thể refresh lại trang web để kiểm tra màu sắc trên menu header nhé, nó đã được sửa lại thành màu đen rồi!
|
Bạn có thể refresh lại trang web để kiểm tra màu sắc trên menu header nhé, nó đã được sửa lại thành màu đen rồi!
|
||||||
|
|
||||||
|
## How to Debug (VS Code)
|
||||||
|
|
||||||
|
1. **Start the Application in Debug Mode**:
|
||||||
|
Open a terminal in the project directory (`sisvietnamvn_main`) and run:
|
||||||
|
```bash
|
||||||
|
./gradlew bootRun --debug-jvm
|
||||||
|
```
|
||||||
|
The application will start and pause, displaying: `Listening for transport dt_socket at address: 5005`.
|
||||||
|
|
||||||
|
2. **Configure VS Code**:
|
||||||
|
Ensure your `.vscode/launch.json` has the following configuration:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "java",
|
||||||
|
"name": "Debug (Attach) - Port 5005",
|
||||||
|
"request": "attach",
|
||||||
|
"hostName": "127.0.0.1",
|
||||||
|
"port": 5005,
|
||||||
|
"projectName": "sisvietnamvn"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Attach the Debugger**:
|
||||||
|
- Go to the **Run and Debug** view in VS Code (`Ctrl+Shift+D`).
|
||||||
|
- Select **Debug (Attach) - Port 5005** from the dropdown at the top.
|
||||||
|
- Click the green **Play** button (or press `F5`).
|
||||||
|
- The application will resume booting.
|
||||||
|
|
||||||
|
4. **Set Breakpoints & Debug**:
|
||||||
|
- Open any Java file (e.g., `SwiperSliderPlugin.java`).
|
||||||
|
- Click in the left margin next to a line number to place a red breakpoint.
|
||||||
|
- Trigger the code (e.g., by navigating to the page in your browser). Execution will pause at your breakpoint, allowing you to inspect variables!
|
||||||
@@ -0,0 +1,458 @@
|
|||||||
|
with open("src/main/resources/templates/plugins/swiper-slider/admin-form.html", "w", encoding="utf-8") as f:
|
||||||
|
f.write("""<html
|
||||||
|
lang="en"
|
||||||
|
xmlns:th="http://www.thymeleaf.org"
|
||||||
|
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
|
||||||
|
layout:decorate="~{fragments/manage-layout}"
|
||||||
|
>
|
||||||
|
<head>
|
||||||
|
<title th:text="${pageTitle} + ' - SIS Vietnam'">Swiper Slider Settings</title>
|
||||||
|
<style>
|
||||||
|
.item-card {
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 5px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.tab-field {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
.tab-field label {
|
||||||
|
display: block;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 0.2rem;
|
||||||
|
}
|
||||||
|
.tab-field input,
|
||||||
|
.tab-field textarea,
|
||||||
|
.tab-field select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
.tab-field textarea {
|
||||||
|
height: 80px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div layout:fragment="content">
|
||||||
|
<div class="container-fluid">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h1 class="h3 mb-0 text-gray-800" th:text="${pageTitle}">Sửa Nhóm Slider</h1>
|
||||||
|
<a th:href="@{/manage/plugins/swiper-slider}" class="btn btn-secondary shadow-sm"
|
||||||
|
><i class="fas fa-arrow-left fa-sm text-white-50"></i> Quay lại Danh sách</a
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div th:if="${errorMessage}" class="alert alert-danger" th:text="${errorMessage}"></div>
|
||||||
|
|
||||||
|
<form method="post" th:action="@{/manage/plugins/swiper-slider/save}" id="sliderForm">
|
||||||
|
<input type="hidden" name="groupData" id="groupData" />
|
||||||
|
<input type="hidden" name="oldSlug" th:value="${oldSlug}" />
|
||||||
|
|
||||||
|
<div class="card shadow mb-4">
|
||||||
|
<div class="card-header py-3">
|
||||||
|
<h6 class="m-0 font-weight-bold text-primary">Thông tin cơ bản</h6>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-6 tab-field">
|
||||||
|
<label>Tên Nhóm (Chỉ để quản lý)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
onchange="updateGroup('name', this.value)"
|
||||||
|
id="groupName"
|
||||||
|
placeholder="Ví dụ: Đào tạo - Chứng chỉ"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 tab-field">
|
||||||
|
<label>Slug (Dùng cho Shortcode)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
onchange="updateGroup('slug', this.value)"
|
||||||
|
id="groupSlug"
|
||||||
|
placeholder="Ví dụ: chung-chi"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 tab-field mt-3">
|
||||||
|
<label>Ghi chú (Hiển thị ở danh sách)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
onchange="updateGroup('note', this.value)"
|
||||||
|
id="groupNote"
|
||||||
|
placeholder="Ví dụ: Dùng cho trang chủ"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 tab-field mt-3">
|
||||||
|
<label>Mẫu Giao Diện (Layout Type)</label>
|
||||||
|
<select
|
||||||
|
class="form-control"
|
||||||
|
onchange="
|
||||||
|
updateGroup('layoutType', this.value);
|
||||||
|
toggleCustomTemplates(this.value);
|
||||||
|
"
|
||||||
|
id="groupLayoutType"
|
||||||
|
>
|
||||||
|
<option value="">Mặc định (Dựa theo Slug, hỗ trợ tương thích ngược)</option>
|
||||||
|
<option value="gallery">Mẫu 1: Thư viện ảnh (Gallery - ảnh chữ nhật nhỏ)</option>
|
||||||
|
<option value="card">Mẫu 2: Dạng thẻ (Khóa học/Chứng chỉ - ảnh vuông + tiêu đề)</option>
|
||||||
|
<option value="large-image">Mẫu 3: Hình ảnh lớn (Thực hành - ảnh chữ nhật lớn)</option>
|
||||||
|
<option value="custom">Tùy chỉnh (Nhập mã HTML bên dưới)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 tab-field mt-3">
|
||||||
|
<label>Nguồn Dữ Liệu</label>
|
||||||
|
<select
|
||||||
|
class="form-control"
|
||||||
|
onchange="
|
||||||
|
updateGroup('dataSource', this.value);
|
||||||
|
toggleDataSource(this.value);
|
||||||
|
"
|
||||||
|
id="groupDataSource"
|
||||||
|
>
|
||||||
|
<option value="MANUAL">Nhập thủ công</option>
|
||||||
|
<option value="POST">Bài viết (Posts)</option>
|
||||||
|
<option value="PAGE">Trang (Pages)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 tab-field mt-3 dynamic-source-container" style="display: none">
|
||||||
|
<div>
|
||||||
|
<label>Chuyên mục (Categories) (Tùy chọn)</label>
|
||||||
|
<select class="form-control" onchange="updateMultiGroup('categoryIds', this)" id="groupCategoryIds">
|
||||||
|
<option value="">-- Chọn chuyên mục --</option>
|
||||||
|
<option th:each="cat : ${allCategories}" th:value="${cat.id}" th:text="${cat.name}"></option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div id="selected_categoryIds"></div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 tab-field mt-3 dynamic-source-container" style="display: none">
|
||||||
|
<div>
|
||||||
|
<label>Thẻ (Tags) (Tùy chọn)</label>
|
||||||
|
<select class="form-control" onchange="updateMultiGroup('tagIds', this)" id="groupTagIds">
|
||||||
|
<option value="">-- Chọn thẻ --</option>
|
||||||
|
<option th:each="tag : ${allTags}" th:value="${tag.id}" th:text="${tag.name}"></option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div id="selected_tagIds"></div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 tab-field mt-3 dynamic-source-container" style="display: none">
|
||||||
|
<label>Số lượng hiển thị (Tùy chọn)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
class="form-control"
|
||||||
|
onchange="updateGroup('itemLimit', this.value ? parseInt(this.value) : null)"
|
||||||
|
id="groupItemLimit"
|
||||||
|
placeholder="Ví dụ: 10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 tab-field mt-3 dynamic-source-container" style="display: none">
|
||||||
|
<div class="form-check mt-4">
|
||||||
|
<input
|
||||||
|
class="form-check-input"
|
||||||
|
type="checkbox"
|
||||||
|
id="groupUseExcerptAsDescription"
|
||||||
|
onchange="updateGroup('useExcerptAsDescription', this.checked)"
|
||||||
|
/>
|
||||||
|
<label class="form-check-label" for="groupUseExcerptAsDescription"> Dùng Excerpt làm Description </label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-12 tab-field mt-3 custom-template-container" style="display: none">
|
||||||
|
<label>Outer Template (Cấu trúc bao ngoài Slider, tùy chọn)</label>
|
||||||
|
<textarea
|
||||||
|
class="form-control"
|
||||||
|
style="font-family: monospace; height: 120px"
|
||||||
|
onchange="updateGroup('outerTemplate', this.value)"
|
||||||
|
id="groupOuterTemplate"
|
||||||
|
placeholder="Ví dụ: <div class='swiper swiper-{{slug}}'><div class='swiper-wrapper'>{{#each items}}{{/each}}</div></div>"
|
||||||
|
></textarea>
|
||||||
|
<small class="text-muted"
|
||||||
|
>Dùng <code>{{slug}}</code> cho slug của nhóm và <code>{{#each items}}{{/each}}</code> để đánh dấu nơi đặt nội dung
|
||||||
|
slides.</small
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-12 tab-field mt-2 custom-template-container" style="display: none">
|
||||||
|
<label>Item Template (Cấu trúc 1 Slide con, tùy chọn)</label>
|
||||||
|
<textarea
|
||||||
|
class="form-control"
|
||||||
|
style="font-family: monospace; height: 100px"
|
||||||
|
onchange="updateGroup('itemTemplate', this.value)"
|
||||||
|
id="groupItemTemplate"
|
||||||
|
placeholder="Ví dụ: <div class='swiper-slide'><img src='{{imageUrl}}' alt='{{title}}'></div>"
|
||||||
|
></textarea>
|
||||||
|
<small class="text-muted"
|
||||||
|
>Các biến hỗ trợ: <code>{{title}}</code>, <code>{{imageUrl}}</code>, <code>{{linkUrl}}</code>,
|
||||||
|
<code>{{description}}</code>.</small
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card shadow mb-4">
|
||||||
|
<div class="card-header py-3 d-flex justify-content-between align-items-center">
|
||||||
|
<h6 class="m-0 font-weight-bold text-primary">Danh sách Slides</h6>
|
||||||
|
<button type="button" class="btn btn-sm btn-info" onclick="addItem()">+ Thêm Slide Mới</button>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div id="itemsContainer"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-primary btn-lg mb-4" onclick="return saveGroup();">Lưu Nhóm Slider</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script th:inline="javascript">
|
||||||
|
let group = /*[[${groupJson}]]*/ '{}';
|
||||||
|
if (typeof group === 'string') {
|
||||||
|
try {
|
||||||
|
group = JSON.parse(group);
|
||||||
|
} catch (e) {
|
||||||
|
group = { slug: '', name: '', layoutType: '', outerTemplate: '', itemTemplate: '', items: [] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!group.items) group.items = [];
|
||||||
|
window.group = group;
|
||||||
|
|
||||||
|
function renderBadge(container, field, val, text) {
|
||||||
|
const badge = document.createElement('span');
|
||||||
|
badge.className = 'category-badge';
|
||||||
|
badge.setAttribute('data-value', val);
|
||||||
|
badge.setAttribute('data-field', field);
|
||||||
|
|
||||||
|
const badgeText = document.createElement('span');
|
||||||
|
badgeText.className = 'badge-text';
|
||||||
|
badgeText.innerText = text;
|
||||||
|
|
||||||
|
const removeBtn = document.createElement('button');
|
||||||
|
removeBtn.type = 'button';
|
||||||
|
removeBtn.className = 'remove_category-badge';
|
||||||
|
removeBtn.innerHTML = '×';
|
||||||
|
|
||||||
|
badge.appendChild(badgeText);
|
||||||
|
badge.appendChild(removeBtn);
|
||||||
|
container.appendChild(badge);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderGroup() {
|
||||||
|
document.getElementById('groupName').value = group.name || '';
|
||||||
|
document.getElementById('groupSlug').value = group.slug || '';
|
||||||
|
document.getElementById('groupNote').value = group.note || '';
|
||||||
|
document.getElementById('groupLayoutType').value = group.layoutType || '';
|
||||||
|
document.getElementById('groupOuterTemplate').value = group.outerTemplate || '';
|
||||||
|
document.getElementById('groupItemTemplate').value = group.itemTemplate || '';
|
||||||
|
|
||||||
|
toggleCustomTemplates(group.layoutType || '');
|
||||||
|
toggleDataSource(group.dataSource || 'MANUAL');
|
||||||
|
|
||||||
|
document.getElementById('groupDataSource').value = group.dataSource || 'MANUAL';
|
||||||
|
|
||||||
|
// Initialize categories badges
|
||||||
|
if (group.categoryIds && Array.isArray(group.categoryIds)) {
|
||||||
|
const selectEl = document.getElementById('groupCategoryIds');
|
||||||
|
const container = document.getElementById('selected_categoryIds');
|
||||||
|
container.innerHTML = '';
|
||||||
|
group.categoryIds.forEach(val => {
|
||||||
|
const optionEl = selectEl.querySelector(`option[value="${val}"]`);
|
||||||
|
const text = optionEl ? optionEl.innerText : val;
|
||||||
|
renderBadge(container, 'categoryIds', val, text);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize tag badges
|
||||||
|
if (group.tagIds && Array.isArray(group.tagIds)) {
|
||||||
|
const selectEl = document.getElementById('groupTagIds');
|
||||||
|
const container = document.getElementById('selected_tagIds');
|
||||||
|
container.innerHTML = '';
|
||||||
|
group.tagIds.forEach(val => {
|
||||||
|
const optionEl = selectEl.querySelector(`option[value="${val}"]`);
|
||||||
|
const text = optionEl ? optionEl.innerText : val;
|
||||||
|
renderBadge(container, 'tagIds', val, text);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('groupItemLimit').value = group.itemLimit || '';
|
||||||
|
document.getElementById('groupUseExcerptAsDescription').checked = group.useExcerptAsDescription || false;
|
||||||
|
|
||||||
|
const container = document.getElementById('itemsContainer');
|
||||||
|
container.innerHTML = '';
|
||||||
|
|
||||||
|
if (group.items.length === 0) {
|
||||||
|
container.innerHTML = '<p class="text-center text-muted my-3">Chưa có slide nào. Bấm "Thêm Slide Mới" để bắt đầu.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
group.items.forEach((item, iIndex) => {
|
||||||
|
const itemHtml = `
|
||||||
|
<div class="item-card shadow-sm">
|
||||||
|
<div class="d-flex justify-content-between mb-2">
|
||||||
|
<h6 class="m-0 font-weight-bold text-secondary">Slide ${iIndex + 1}</h6>
|
||||||
|
<button type="button" class="btn btn-danger btn-sm" onclick="removeItem(${iIndex})">Xóa Slide</button>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6 tab-field">
|
||||||
|
<label>Tiêu đề</label>
|
||||||
|
<input type="text" onchange="updateItem(${iIndex}, 'title', this.value)" value="${item.title || ''}" />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 tab-field">
|
||||||
|
<label>Link đích (URL khi click)</label>
|
||||||
|
<input type="text" onchange="updateItem(${iIndex}, 'linkUrl', this.value)" value="${item.linkUrl || ''}" />
|
||||||
|
</div>
|
||||||
|
<div class="col-md-12 tab-field">
|
||||||
|
<label>Hình Ảnh (URL)</label>
|
||||||
|
<div class="d-flex">
|
||||||
|
<input type="text" id="img_${iIndex}" class="form-control" onchange="updateItem(${iIndex}, 'imageUrl', this.value)" value="${item.imageUrl || ''}" />
|
||||||
|
<button type="button" class="btn btn-secondary ml-2" onclick="document.getElementById('file_${iIndex}').click()" style="white-space: nowrap;">Tải Ảnh Lên</button>
|
||||||
|
</div>
|
||||||
|
<input type="file" id="file_${iIndex}" style="display:none;" onchange="uploadImage(${iIndex}, this.files[0])" accept="image/*"/>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-12 tab-field">
|
||||||
|
<label>Mô tả (HTML)</label>
|
||||||
|
<textarea onchange="updateItem(${iIndex}, 'description', this.value)">${item.description || ''}</textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
container.insertAdjacentHTML('beforeend', itemHtml);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleCustomTemplates(layoutType) {
|
||||||
|
const display = layoutType === 'custom' ? 'block' : 'none';
|
||||||
|
document.querySelectorAll('.custom-template-container').forEach(el => (el.style.display = display));
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleDataSource(dataSource) {
|
||||||
|
const display = dataSource === 'POST' || dataSource === 'PAGE' ? 'block' : 'none';
|
||||||
|
document.querySelectorAll('.dynamic-source-container').forEach(el => (el.style.display = display));
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateGroup(field, value) {
|
||||||
|
group[field] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.updateMultiGroup = function (field, selectElement) {
|
||||||
|
const selectedValue = parseInt(selectElement.value, 10);
|
||||||
|
|
||||||
|
// Ignore default empty option selection
|
||||||
|
if (isNaN(selectedValue)) return;
|
||||||
|
|
||||||
|
// Initialize array state for this field if not present
|
||||||
|
if (!Array.isArray(window.group[field])) {
|
||||||
|
window.group[field] = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Add ID to array if not already selected (prevents duplicates)
|
||||||
|
if (!window.group[field].includes(selectedValue)) {
|
||||||
|
window.group[field].push(selectedValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Target the corresponding badge container
|
||||||
|
const container = document.getElementById('selected_' + field);
|
||||||
|
container.innerHTML = '';
|
||||||
|
|
||||||
|
// 3. Render badges from the window.group[field] array
|
||||||
|
window.group[field].forEach(val => {
|
||||||
|
const optionEl = selectElement.querySelector(`option[value="${val}"]`);
|
||||||
|
const text = optionEl ? optionEl.innerText : val;
|
||||||
|
renderBadge(container, field, val, text);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. Reset select dropdown back to placeholder option
|
||||||
|
selectElement.selectedIndex = 0;
|
||||||
|
|
||||||
|
console.log(`Current group[${field}]:`, window.group[field]);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Universal Remove Handler using Event Delegation in Vanilla JS
|
||||||
|
document.addEventListener('click', function (e) {
|
||||||
|
if (e.target && (e.target.matches('.remove_category-badge') || e.target.closest('.remove_category-badge'))) {
|
||||||
|
e.stopPropagation();
|
||||||
|
const badge = e.target.closest('.category-badge');
|
||||||
|
if (!badge) return;
|
||||||
|
|
||||||
|
const badgeVal = parseInt(badge.getAttribute('data-value'), 10);
|
||||||
|
const field = badge.getAttribute('data-field');
|
||||||
|
|
||||||
|
if (!isNaN(badgeVal) && field && Array.isArray(window.group[field])) {
|
||||||
|
// 1. Filter out deleted ID from the state array
|
||||||
|
window.group[field] = window.group[field].filter(val => val !== badgeVal);
|
||||||
|
console.log(`Updated group[${field}] after removal:`, window.group[field]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Remove badge UI element
|
||||||
|
badge.remove();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function addItem() {
|
||||||
|
group.items.push({ title: '', imageUrl: '', linkUrl: '', description: '' });
|
||||||
|
renderGroup();
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeItem(iIndex) {
|
||||||
|
if (confirm('Bạn có chắc muốn xóa slide này?')) {
|
||||||
|
group.items.splice(iIndex, 1);
|
||||||
|
renderGroup();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateItem(iIndex, field, value) {
|
||||||
|
group.items[iIndex][field] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadImage(iIndex, file) {
|
||||||
|
if (!file) return;
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
|
||||||
|
const btn = document.querySelector(`#file_${iIndex}`).previousElementSibling.querySelector('button');
|
||||||
|
const originalText = btn.innerText;
|
||||||
|
btn.innerText = 'Đang tải...';
|
||||||
|
btn.disabled = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/manage/media/upload', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
const result = await response.json();
|
||||||
|
if (result.success === 1 && result.file && result.file.url) {
|
||||||
|
group.items[iIndex]['imageUrl'] = result.file.url;
|
||||||
|
document.getElementById(`img_${iIndex}`).value = result.file.url;
|
||||||
|
} else {
|
||||||
|
alert('Lỗi tải ảnh lên!');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
alert('Lỗi tải ảnh lên!');
|
||||||
|
} finally {
|
||||||
|
btn.innerText = originalText;
|
||||||
|
btn.disabled = false;
|
||||||
|
document.getElementById(`file_${iIndex}`).value = ''; // reset file input
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveGroup() {
|
||||||
|
if (!group.name || !group.slug) {
|
||||||
|
alert('Vui lòng nhập Tên Nhóm và Slug!');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
document.getElementById('groupData').value = JSON.stringify(group);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
renderGroup();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>""")
|
||||||
@@ -48,7 +48,7 @@ public class SecurityConfiguration {
|
|||||||
@Bean
|
@Bean
|
||||||
public SecurityFilterChain filterChain(HttpSecurity http, CustomOidcUserService customOidcUserService) {
|
public SecurityFilterChain filterChain(HttpSecurity http, CustomOidcUserService customOidcUserService) {
|
||||||
http.cors(withDefaults())
|
http.cors(withDefaults())
|
||||||
.csrf(withDefaults())
|
.csrf(csrf -> csrf.ignoringRequestMatchers("/api/manage/media/upload"))
|
||||||
.authorizeHttpRequests(authz ->
|
.authorizeHttpRequests(authz ->
|
||||||
// prettier-ignore
|
// prettier-ignore
|
||||||
authz
|
authz
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ public class ManagePostController {
|
|||||||
@RequestParam(value = "status", required = false) String statusStr,
|
@RequestParam(value = "status", required = false) String statusStr,
|
||||||
@RequestParam(value = "search", required = false) String search,
|
@RequestParam(value = "search", required = false) String search,
|
||||||
@RequestParam(value = "page", defaultValue = "0") int page,
|
@RequestParam(value = "page", defaultValue = "0") int page,
|
||||||
@RequestParam(value = "size", defaultValue = "10") int size,
|
@RequestParam(value = "size", defaultValue = "10000") int size,
|
||||||
Model model) {
|
Model model) {
|
||||||
LOG.debug("Request to list all posts (categoryId={}, status={}, search={}, page={}, size={})", categoryId, statusStr, search, page, size);
|
LOG.debug("Request to list all posts (categoryId={}, status={}, search={}, page={}, size={})", categoryId, statusStr, search, page, size);
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import com.sisvietnamvn.web.security.AuthoritiesConstants;
|
|||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -69,4 +70,9 @@ public class MediaController {
|
|||||||
return ResponseEntity.internalServerError().body(response);
|
return ResponseEntity.internalServerError().body(response);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/list")
|
||||||
|
public ResponseEntity<List<Media>> getMediaList() {
|
||||||
|
return ResponseEntity.ok(mediaService.findAll());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ public class DoctorSchedule implements Serializable {
|
|||||||
private Doctor doctor;
|
private Doctor doctor;
|
||||||
|
|
||||||
@Column(name = "schedule_date", length = 50, nullable = false)
|
@Column(name = "schedule_date", length = 50, nullable = false)
|
||||||
private String date;
|
private String scheduleDate;
|
||||||
|
|
||||||
@Column(name = "day_of_week", nullable = false)
|
@Column(name = "day_of_week", nullable = false)
|
||||||
private Integer dayOfWeek;
|
private Integer dayOfWeek;
|
||||||
@@ -47,12 +47,20 @@ public class DoctorSchedule implements Serializable {
|
|||||||
this.doctor = doctor;
|
this.doctor = doctor;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public String getScheduleDate() {
|
||||||
|
return scheduleDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setScheduleDate(String scheduleDate) {
|
||||||
|
this.scheduleDate = scheduleDate;
|
||||||
|
}
|
||||||
|
|
||||||
public String getDate() {
|
public String getDate() {
|
||||||
return date;
|
return scheduleDate;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void setDate(String date) {
|
public void setDate(String date) {
|
||||||
this.date = date;
|
this.scheduleDate = date;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Integer getDayOfWeek() {
|
public Integer getDayOfWeek() {
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import jakarta.validation.constraints.NotNull;
|
|||||||
import jakarta.validation.constraints.Size;
|
import jakarta.validation.constraints.Size;
|
||||||
import java.io.Serial;
|
import java.io.Serial;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.HashSet;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A CMS Page entity representing a static page (e.g. About Us, Contact, Working Hours).
|
* A CMS Page entity representing a static page (e.g. About Us, Contact, Working Hours).
|
||||||
@@ -73,6 +75,14 @@ public class Page extends AbstractAuditingEntity<Long> {
|
|||||||
@Column(name = "specialty_category", length = 50)
|
@Column(name = "specialty_category", length = 50)
|
||||||
private String specialtyCategory;
|
private String specialtyCategory;
|
||||||
|
|
||||||
|
@ManyToMany(fetch = FetchType.LAZY)
|
||||||
|
@JoinTable(
|
||||||
|
name = "sis_page_tag",
|
||||||
|
joinColumns = @JoinColumn(name = "page_id"),
|
||||||
|
inverseJoinColumns = @JoinColumn(name = "tag_id")
|
||||||
|
)
|
||||||
|
private Set<Tag> tags = new HashSet<>();
|
||||||
|
|
||||||
// --- Getters and Setters ---
|
// --- Getters and Setters ---
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -100,6 +110,14 @@ public class Page extends AbstractAuditingEntity<Long> {
|
|||||||
this.slug = slug;
|
this.slug = slug;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Set<Tag> getTags() {
|
||||||
|
return tags;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setTags(Set<Tag> tags) {
|
||||||
|
this.tags = tags;
|
||||||
|
}
|
||||||
|
|
||||||
public String getContent() {
|
public String getContent() {
|
||||||
return content;
|
return content;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
package com.sisvietnamvn.web.plugins.sampleplugin;
|
package com.sisvietnamvn.web.plugins.sampleplugin.sampleplugin;
|
||||||
|
|
||||||
import com.sisvietnamvn.web.domain.Page;
|
import com.sisvietnamvn.web.domain.Page;
|
||||||
import com.sisvietnamvn.web.hook.HookManager;
|
import com.sisvietnamvn.web.hook.HookManager;
|
||||||
|
|||||||
@@ -20,12 +20,23 @@ public class SwiperSliderAdminController {
|
|||||||
private final SettingService settingService;
|
private final SettingService settingService;
|
||||||
private final AdminContext adminContext;
|
private final AdminContext adminContext;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
|
private final com.sisvietnamvn.web.service.CategoryService categoryService;
|
||||||
|
private final com.sisvietnamvn.web.service.TagService tagService;
|
||||||
|
private final com.sisvietnamvn.web.service.ComponentTemplateService componentTemplateService;
|
||||||
private static final String SETTING_KEY = "plugin_swiper_slider_data";
|
private static final String SETTING_KEY = "plugin_swiper_slider_data";
|
||||||
|
|
||||||
public SwiperSliderAdminController(SettingService settingService, AdminContext adminContext, ObjectMapper objectMapper) {
|
public SwiperSliderAdminController(SettingService settingService,
|
||||||
|
AdminContext adminContext,
|
||||||
|
ObjectMapper objectMapper,
|
||||||
|
com.sisvietnamvn.web.service.CategoryService categoryService,
|
||||||
|
com.sisvietnamvn.web.service.TagService tagService,
|
||||||
|
com.sisvietnamvn.web.service.ComponentTemplateService componentTemplateService) {
|
||||||
this.settingService = settingService;
|
this.settingService = settingService;
|
||||||
this.adminContext = adminContext;
|
this.adminContext = adminContext;
|
||||||
this.objectMapper = objectMapper;
|
this.objectMapper = objectMapper;
|
||||||
|
this.categoryService = categoryService;
|
||||||
|
this.tagService = tagService;
|
||||||
|
this.componentTemplateService = componentTemplateService;
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<SliderGroup> getAllGroups() {
|
private List<SliderGroup> getAllGroups() {
|
||||||
@@ -68,6 +79,9 @@ public class SwiperSliderAdminController {
|
|||||||
model.addAttribute("groupJson", "{\"slug\":\"\", \"name\":\"\", \"layoutType\":\"\", \"outerTemplate\":\"\", \"itemTemplate\":\"\", \"items\":[]}");
|
model.addAttribute("groupJson", "{\"slug\":\"\", \"name\":\"\", \"layoutType\":\"\", \"outerTemplate\":\"\", \"itemTemplate\":\"\", \"items\":[]}");
|
||||||
model.addAttribute("isNew", true);
|
model.addAttribute("isNew", true);
|
||||||
model.addAttribute("pageTitle", "Thêm Nhóm Slider Mới");
|
model.addAttribute("pageTitle", "Thêm Nhóm Slider Mới");
|
||||||
|
model.addAttribute("allCategories", categoryService.findAll());
|
||||||
|
model.addAttribute("allTags", tagService.findAll());
|
||||||
|
model.addAttribute("componentTemplates", componentTemplateService.findAll());
|
||||||
|
|
||||||
return "plugins/swiper-slider/admin-form";
|
return "plugins/swiper-slider/admin-form";
|
||||||
}
|
}
|
||||||
@@ -92,6 +106,9 @@ public class SwiperSliderAdminController {
|
|||||||
model.addAttribute("oldSlug", slug);
|
model.addAttribute("oldSlug", slug);
|
||||||
model.addAttribute("isNew", false);
|
model.addAttribute("isNew", false);
|
||||||
model.addAttribute("pageTitle", "Sửa Nhóm Slider: " + group.name());
|
model.addAttribute("pageTitle", "Sửa Nhóm Slider: " + group.name());
|
||||||
|
model.addAttribute("allCategories", categoryService.findAll());
|
||||||
|
model.addAttribute("allTags", tagService.findAll());
|
||||||
|
model.addAttribute("componentTemplates", componentTemplateService.findAll());
|
||||||
|
|
||||||
return "plugins/swiper-slider/admin-form";
|
return "plugins/swiper-slider/admin-form";
|
||||||
}
|
}
|
||||||
@@ -151,11 +168,35 @@ public class SwiperSliderAdminController {
|
|||||||
return "redirect:/manage/plugins/swiper-slider";
|
return "redirect:/manage/plugins/swiper-slider";
|
||||||
}
|
}
|
||||||
|
|
||||||
public record SliderGroup(String slug, String name, String layoutType, String outerTemplate, String itemTemplate, String note, List<SliderItem> items) {
|
public record SliderGroup(
|
||||||
|
String slug,
|
||||||
|
String name,
|
||||||
|
String layoutType,
|
||||||
|
String outerTemplate,
|
||||||
|
String itemTemplate,
|
||||||
|
String note,
|
||||||
|
String dataSource,
|
||||||
|
Long categoryId,
|
||||||
|
Long tagId,
|
||||||
|
List<Long> categoryIds,
|
||||||
|
List<Long> tagIds,
|
||||||
|
List<Long> mediaIds,
|
||||||
|
Integer itemLimit,
|
||||||
|
Boolean useExcerptAsDescription,
|
||||||
|
List<SliderItem> items
|
||||||
|
) {
|
||||||
|
public SliderGroup(String slug, String name, String layoutType, String outerTemplate, String itemTemplate, String note, List<SliderItem> items) {
|
||||||
|
this(slug, name, layoutType, outerTemplate, itemTemplate, note, null, null, null, null, null, null, null, null, items);
|
||||||
|
}
|
||||||
public String getSlug() { return slug; }
|
public String getSlug() { return slug; }
|
||||||
public String getName() { return name; }
|
public String getName() { return name; }
|
||||||
public String getLayoutType() { return layoutType; }
|
public String getLayoutType() { return layoutType; }
|
||||||
public String getNote() { return note; }
|
public String getNote() { return note; }
|
||||||
|
public String getDataSource() { return dataSource; }
|
||||||
|
public List<Long> getCategoryIds() { return categoryIds; }
|
||||||
|
public List<Long> getTagIds() { return tagIds; }
|
||||||
|
public Integer getItemLimit() { return itemLimit; }
|
||||||
|
public Boolean getUseExcerptAsDescription() { return useExcerptAsDescription; }
|
||||||
public List<SliderItem> getItems() { return items; }
|
public List<SliderItem> getItems() { return items; }
|
||||||
}
|
}
|
||||||
public record SliderItem(String title, String imageUrl, String linkUrl, String description, String badge, String price, String dateStr) {}
|
public record SliderItem(String title, String imageUrl, String linkUrl, String description, String badge, String price, String dateStr) {}
|
||||||
|
|||||||
@@ -34,17 +34,20 @@ public class SwiperSliderPlugin {
|
|||||||
private final HookManager hookManager;
|
private final HookManager hookManager;
|
||||||
private final SettingService settingService;
|
private final SettingService settingService;
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
|
private final jakarta.persistence.EntityManager entityManager;
|
||||||
|
|
||||||
public SwiperSliderPlugin(PluginRepository pluginRepository,
|
public SwiperSliderPlugin(PluginRepository pluginRepository,
|
||||||
ComponentTemplateRepository templateRepository,
|
ComponentTemplateRepository templateRepository,
|
||||||
HookManager hookManager,
|
HookManager hookManager,
|
||||||
SettingService settingService,
|
SettingService settingService,
|
||||||
ObjectMapper objectMapper) {
|
ObjectMapper objectMapper,
|
||||||
|
jakarta.persistence.EntityManager entityManager) {
|
||||||
this.pluginRepository = pluginRepository;
|
this.pluginRepository = pluginRepository;
|
||||||
this.templateRepository = templateRepository;
|
this.templateRepository = templateRepository;
|
||||||
this.hookManager = hookManager;
|
this.hookManager = hookManager;
|
||||||
this.settingService = settingService;
|
this.settingService = settingService;
|
||||||
this.objectMapper = objectMapper;
|
this.objectMapper = objectMapper;
|
||||||
|
this.entityManager = entityManager;
|
||||||
}
|
}
|
||||||
|
|
||||||
@EventListener(ApplicationReadyEvent.class)
|
@EventListener(ApplicationReadyEvent.class)
|
||||||
@@ -76,7 +79,6 @@ public class SwiperSliderPlugin {
|
|||||||
LOG.info("SwiperSliderPlugin is active. Seeding components and hooks...");
|
LOG.info("SwiperSliderPlugin is active. Seeding components and hooks...");
|
||||||
|
|
||||||
seedComponentTemplate();
|
seedComponentTemplate();
|
||||||
seedDefaultData();
|
|
||||||
|
|
||||||
// Hook to add menu to Plugins dropdown
|
// Hook to add menu to Plugins dropdown
|
||||||
hookManager.addFilter("admin_menu_plugins", (content, args) -> {
|
hookManager.addFilter("admin_menu_plugins", (content, args) -> {
|
||||||
@@ -115,7 +117,7 @@ public class SwiperSliderPlugin {
|
|||||||
return text;
|
return text;
|
||||||
}
|
}
|
||||||
|
|
||||||
Pattern pattern = Pattern.compile("\\[plugin:swiper-slider slug=\"([^\"]+)\"\\]");
|
Pattern pattern = Pattern.compile("\\[plugin:swiper-slider slug=[\"']?([^\"'\\] ]+)[\"']?\\]");
|
||||||
Matcher matcher = pattern.matcher(text);
|
Matcher matcher = pattern.matcher(text);
|
||||||
StringBuilder result = new StringBuilder();
|
StringBuilder result = new StringBuilder();
|
||||||
|
|
||||||
@@ -132,71 +134,9 @@ public class SwiperSliderPlugin {
|
|||||||
String json = settingService.getValue(SETTING_KEY, "[]");
|
String json = settingService.getValue(SETTING_KEY, "[]");
|
||||||
List<SwiperSliderAdminController.SliderGroup> groups = parseGroups(json);
|
List<SwiperSliderAdminController.SliderGroup> groups = parseGroups(json);
|
||||||
final String searchSlug = ("dao-tao-01".equals(slug)) ? "gallery" : slug;
|
final String searchSlug = ("dao-tao-01".equals(slug)) ? "gallery" : slug;
|
||||||
Optional<SwiperSliderAdminController.SliderGroup> groupOpt = groups.stream()
|
return groups.stream()
|
||||||
.filter(g -> slug.equals(g.slug()) || searchSlug.equals(g.slug()))
|
.filter(g -> slug.equals(g.slug()) || searchSlug.equals(g.slug()))
|
||||||
.findFirst();
|
.findFirst();
|
||||||
|
|
||||||
// If UMC_files is present or slug not found, re-seed full data
|
|
||||||
if (groupOpt.isEmpty() || json.contains("UMC_files")) {
|
|
||||||
json = forceSeedAllData();
|
|
||||||
groups = parseGroups(json);
|
|
||||||
groupOpt = groups.stream()
|
|
||||||
.filter(g -> slug.equals(g.slug()) || searchSlug.equals(g.slug()))
|
|
||||||
.findFirst();
|
|
||||||
}
|
|
||||||
|
|
||||||
return groupOpt;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static final String DEFAULT_SLIDER_DATA = "[" +
|
|
||||||
"{\"slug\":\"dao-tao-01\",\"name\":\"Gallery Đào Tạo\",\"items\":[" +
|
|
||||||
"{\"title\":\"Khóa đào tạo 1\",\"imageUrl\":\"/images/dao-tao/image-6.jpeg\",\"linkUrl\":\"\",\"description\":\"\"}," +
|
|
||||||
"{\"title\":\"Khóa đào tạo 2\",\"imageUrl\":\"/images/dao-tao/image-5.jpeg\",\"linkUrl\":\"\",\"description\":\"\"}," +
|
|
||||||
"{\"title\":\"Khóa đào tạo 3\",\"imageUrl\":\"/images/dao-tao/image-4.jpeg\",\"linkUrl\":\"\",\"description\":\"\"}," +
|
|
||||||
"{\"title\":\"Khóa đào tạo 4\",\"imageUrl\":\"/images/dao-tao/image-3.jpeg\",\"linkUrl\":\"\",\"description\":\"\"}," +
|
|
||||||
"{\"title\":\"Khóa đào tạo 5\",\"imageUrl\":\"/images/dao-tao/image-2.jpeg\",\"linkUrl\":\"\",\"description\":\"\"}," +
|
|
||||||
"{\"title\":\"Khóa đào tạo 6\",\"imageUrl\":\"/images/dao-tao/image-1.jpeg\",\"linkUrl\":\"\",\"description\":\"\"}" +
|
|
||||||
"]},{\"slug\":\"gallery\",\"name\":\"Gallery Đào Tạo\",\"items\":[" +
|
|
||||||
"{\"title\":\"Khóa đào tạo 1\",\"imageUrl\":\"/images/dao-tao/image-6.jpeg\",\"linkUrl\":\"\",\"description\":\"\"}," +
|
|
||||||
"{\"title\":\"Khóa đào tạo 2\",\"imageUrl\":\"/images/dao-tao/image-5.jpeg\",\"linkUrl\":\"\",\"description\":\"\"}," +
|
|
||||||
"{\"title\":\"Khóa đào tạo 3\",\"imageUrl\":\"/images/dao-tao/image-4.jpeg\",\"linkUrl\":\"\",\"description\":\"\"}," +
|
|
||||||
"{\"title\":\"Khóa đào tạo 4\",\"imageUrl\":\"/images/dao-tao/image-3.jpeg\",\"linkUrl\":\"\",\"description\":\"\"}," +
|
|
||||||
"{\"title\":\"Khóa đào tạo 5\",\"imageUrl\":\"/images/dao-tao/image-2.jpeg\",\"linkUrl\":\"\",\"description\":\"\"}," +
|
|
||||||
"{\"title\":\"Khóa đào tạo 6\",\"imageUrl\":\"/images/dao-tao/image-1.jpeg\",\"linkUrl\":\"\",\"description\":\"\"}" +
|
|
||||||
"]},{\"slug\":\"chung-chi\",\"name\":\"Cấp chứng chỉ\",\"items\":[" +
|
|
||||||
"{\"title\":\"Lớp chứng chỉ 1\",\"imageUrl\":\"/images/dao-tao/image-3.jpeg\",\"linkUrl\":\"\",\"description\":\"\"}," +
|
|
||||||
"{\"title\":\"Lớp chứng chỉ 2\",\"imageUrl\":\"/images/dao-tao/image-2.jpeg\",\"linkUrl\":\"\",\"description\":\"\"}," +
|
|
||||||
"{\"title\":\"Lớp chứng chỉ 3\",\"imageUrl\":\"/images/dao-tao/image-1.jpeg\",\"linkUrl\":\"\",\"description\":\"\"}," +
|
|
||||||
"{\"title\":\"Lớp chứng chỉ 4\",\"imageUrl\":\"/images/dao-tao/training-1.jpeg\",\"linkUrl\":\"\",\"description\":\"\"}," +
|
|
||||||
"{\"title\":\"Lớp chứng chỉ 5\",\"imageUrl\":\"/images/dao-tao/training-2.jpeg\",\"linkUrl\":\"\",\"description\":\"\"}," +
|
|
||||||
"{\"title\":\"Lớp chứng chỉ 6\",\"imageUrl\":\"/images/dao-tao/training-3.jpeg\",\"linkUrl\":\"\",\"description\":\"\"}" +
|
|
||||||
"]},{\"slug\":\"giay-chung-nhan\",\"name\":\"Cấp giấy chứng nhận\",\"items\":[" +
|
|
||||||
"{\"title\":\"Giấy chứng nhận 1\",\"imageUrl\":\"/images/dao-tao/training-4.jpeg\",\"linkUrl\":\"\",\"description\":\"\"}," +
|
|
||||||
"{\"title\":\"Giấy chứng nhận 2\",\"imageUrl\":\"/images/dao-tao/training-3.jpeg\",\"linkUrl\":\"\",\"description\":\"\"}," +
|
|
||||||
"{\"title\":\"Giấy chứng nhận 3\",\"imageUrl\":\"/images/dao-tao/training-2.jpeg\",\"linkUrl\":\"\",\"description\":\"\"}," +
|
|
||||||
"{\"title\":\"Giấy chứng nhận 4\",\"imageUrl\":\"/images/dao-tao/training-1.jpeg\",\"linkUrl\":\"\",\"description\":\"\"}," +
|
|
||||||
"{\"title\":\"Giấy chứng nhận 5\",\"imageUrl\":\"/images/dao-tao/image-5.jpeg\",\"linkUrl\":\"\",\"description\":\"\"}," +
|
|
||||||
"{\"title\":\"Giấy chứng nhận 6\",\"imageUrl\":\"/images/dao-tao/image-6.jpeg\",\"linkUrl\":\"\",\"description\":\"\"}" +
|
|
||||||
"]},{\"slug\":\"xac-nhan-thuc-hanh\",\"name\":\"Đào tạo cấp giấy xác nhận quá trình thực hành\",\"items\":[" +
|
|
||||||
"{\"title\":\"Xác nhận thực hành 1\",\"imageUrl\":\"/images/dao-tao/PTNS2707.jpeg\",\"linkUrl\":\"\",\"description\":\"\"}," +
|
|
||||||
"{\"title\":\"Xác nhận thực hành 2\",\"imageUrl\":\"/images/dao-tao/training-2.jpeg\",\"linkUrl\":\"\",\"description\":\"\"}," +
|
|
||||||
"{\"title\":\"Xác nhận thực hành 3\",\"imageUrl\":\"/images/dao-tao/training-3.jpeg\",\"linkUrl\":\"\",\"description\":\"\"}," +
|
|
||||||
"{\"title\":\"Xác nhận thực hành 4\",\"imageUrl\":\"/images/dao-tao/training-4.jpeg\",\"linkUrl\":\"\",\"description\":\"\"}," +
|
|
||||||
"{\"title\":\"Xác nhận thực hành 5\",\"imageUrl\":\"/images/dao-tao/image-1.jpeg\",\"linkUrl\":\"\",\"description\":\"\"}," +
|
|
||||||
"{\"title\":\"Xác nhận thực hành 6\",\"imageUrl\":\"/images/dao-tao/image-2.jpeg\",\"linkUrl\":\"\",\"description\":\"\"}" +
|
|
||||||
"]},{\"slug\":\"hinh-anh-thuc-hanh\",\"name\":\"Hình ảnh thực hành lâm sàng\",\"items\":[" +
|
|
||||||
"{\"title\":\"Thực hành lâm sàng 1\",\"imageUrl\":\"/images/dao-tao/training-1.jpeg\",\"linkUrl\":\"\",\"description\":\"\"}," +
|
|
||||||
"{\"title\":\"Thực hành lâm sàng 2\",\"imageUrl\":\"/images/dao-tao/training-2.jpeg\",\"linkUrl\":\"\",\"description\":\"\"}," +
|
|
||||||
"{\"title\":\"Thực hành lâm sàng 3\",\"imageUrl\":\"/images/dao-tao/training-3.jpeg\",\"linkUrl\":\"\",\"description\":\"\"}," +
|
|
||||||
"{\"title\":\"Thực hành lâm sàng 4\",\"imageUrl\":\"/images/dao-tao/training-4.jpeg\",\"linkUrl\":\"\",\"description\":\"\"}," +
|
|
||||||
"{\"title\":\"Thực hành lâm sàng 5\",\"imageUrl\":\"/images/dao-tao/image-4.jpeg\",\"linkUrl\":\"\",\"description\":\"\"}," +
|
|
||||||
"{\"title\":\"Thực hành lâm sàng 6\",\"imageUrl\":\"/images/dao-tao/image-5.jpeg\",\"linkUrl\":\"\",\"description\":\"\"}" +
|
|
||||||
"]}]";
|
|
||||||
|
|
||||||
private String forceSeedAllData() {
|
|
||||||
settingService.setValue(SETTING_KEY, DEFAULT_SLIDER_DATA);
|
|
||||||
LOG.info("Forced seeding of default swiper slider data for all training sliders");
|
|
||||||
return DEFAULT_SLIDER_DATA;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<SwiperSliderAdminController.SliderGroup> parseGroups(String json) {
|
private List<SwiperSliderAdminController.SliderGroup> parseGroups(String json) {
|
||||||
@@ -215,7 +155,8 @@ public class SwiperSliderPlugin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
SwiperSliderAdminController.SliderGroup group = groupOpt.get();
|
SwiperSliderAdminController.SliderGroup group = groupOpt.get();
|
||||||
if (group.items() == null || group.items().isEmpty()) {
|
List<SwiperSliderAdminController.SliderItem> dynamicItems = fetchItemsForGroup(group);
|
||||||
|
if (dynamicItems.isEmpty()) {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,7 +182,8 @@ public class SwiperSliderPlugin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
SwiperSliderAdminController.SliderGroup group = groupOpt.get();
|
SwiperSliderAdminController.SliderGroup group = groupOpt.get();
|
||||||
if (group.items() == null || group.items().isEmpty()) {
|
List<SwiperSliderAdminController.SliderItem> dynamicItems = fetchItemsForGroup(group);
|
||||||
|
if (dynamicItems.isEmpty()) {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -259,12 +201,14 @@ public class SwiperSliderPlugin {
|
|||||||
slideTemplate = getSlideTemplateForSlug("course-card");
|
slideTemplate = getSlideTemplateForSlug("course-card");
|
||||||
} else if ("custom".equals(layoutType) && group.itemTemplate() != null && !group.itemTemplate().trim().isEmpty()) {
|
} else if ("custom".equals(layoutType) && group.itemTemplate() != null && !group.itemTemplate().trim().isEmpty()) {
|
||||||
slideTemplate = group.itemTemplate();
|
slideTemplate = group.itemTemplate();
|
||||||
|
} else if (layoutType != null && !layoutType.trim().isEmpty()) {
|
||||||
|
slideTemplate = getSlideTemplateForSlug(layoutType);
|
||||||
} else {
|
} else {
|
||||||
// Fallback to legacy slug-based template if no matching layoutType or empty custom template
|
// Fallback to legacy slug-based template if no matching layoutType or empty custom template
|
||||||
slideTemplate = getSlideTemplateForSlug(slug);
|
slideTemplate = getSlideTemplateForSlug(slug);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (SwiperSliderAdminController.SliderItem item : group.items()) {
|
for (SwiperSliderAdminController.SliderItem item : dynamicItems) {
|
||||||
String slide = slideTemplate
|
String slide = slideTemplate
|
||||||
.replace("{{title}}", item.title() != null ? item.title() : "")
|
.replace("{{title}}", item.title() != null ? item.title() : "")
|
||||||
.replace("{{imageUrl}}", item.imageUrl() != null ? item.imageUrl() : "")
|
.replace("{{imageUrl}}", item.imageUrl() != null ? item.imageUrl() : "")
|
||||||
@@ -278,19 +222,78 @@ public class SwiperSliderPlugin {
|
|||||||
return slidesHtml.toString();
|
return slidesHtml.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private List<SwiperSliderAdminController.SliderItem> fetchItemsForGroup(SwiperSliderAdminController.SliderGroup group) {
|
||||||
|
List<SwiperSliderAdminController.SliderItem> dynamicItems = new ArrayList<>();
|
||||||
|
if ("POST".equals(group.dataSource())) {
|
||||||
|
String jpql = "SELECT p FROM Post p ";
|
||||||
|
List<String> conditions = new ArrayList<>();
|
||||||
|
conditions.add("p.status = 'PUBLISHED'");
|
||||||
|
if (group.categoryIds() != null && !group.categoryIds().isEmpty()) { conditions.add("p.category.id IN :catIds"); }
|
||||||
|
if (group.tagIds() != null && !group.tagIds().isEmpty()) { conditions.add("EXISTS (SELECT 1 FROM p.tags t WHERE t.id IN :tagIds)"); }
|
||||||
|
if (!conditions.isEmpty()) { jpql += "WHERE " + String.join(" AND ", conditions) + " "; }
|
||||||
|
jpql += "ORDER BY p.createdDate DESC";
|
||||||
|
jakarta.persistence.TypedQuery<com.sisvietnamvn.web.domain.Post> q = entityManager.createQuery(jpql, com.sisvietnamvn.web.domain.Post.class);
|
||||||
|
if (group.categoryIds() != null && !group.categoryIds().isEmpty()) {
|
||||||
|
java.util.List<Long> catIds = group.categoryIds().stream().map(Number::longValue).collect(java.util.stream.Collectors.toList());
|
||||||
|
q.setParameter("catIds", catIds);
|
||||||
|
}
|
||||||
|
if (group.tagIds() != null && !group.tagIds().isEmpty()) {
|
||||||
|
java.util.List<Long> tagIds = group.tagIds().stream().map(Number::longValue).collect(java.util.stream.Collectors.toList());
|
||||||
|
q.setParameter("tagIds", tagIds);
|
||||||
|
}
|
||||||
|
if (group.itemLimit() != null && group.itemLimit() > 0) q.setMaxResults(group.itemLimit());
|
||||||
|
List<com.sisvietnamvn.web.domain.Post> posts = q.getResultList();
|
||||||
|
for (com.sisvietnamvn.web.domain.Post p : posts) {
|
||||||
|
String desc = Boolean.TRUE.equals(group.useExcerptAsDescription()) ? p.getExcerpt() : "";
|
||||||
|
dynamicItems.add(new SwiperSliderAdminController.SliderItem(p.getTitle(), p.getFeaturedImage(), "/post/" + p.getSlug(), desc, null, null, null));
|
||||||
|
}
|
||||||
|
} else if ("PAGE".equals(group.dataSource())) {
|
||||||
|
String jpql = "SELECT p FROM Page p ";
|
||||||
|
List<String> conditions = new ArrayList<>();
|
||||||
|
conditions.add("p.status = 'PUBLISHED'");
|
||||||
|
if (group.tagIds() != null && !group.tagIds().isEmpty()) { conditions.add("EXISTS (SELECT 1 FROM p.tags t WHERE t.id IN :tagIds)"); }
|
||||||
|
if (!conditions.isEmpty()) { jpql += "WHERE " + String.join(" AND ", conditions) + " "; }
|
||||||
|
jpql += "ORDER BY p.createdDate DESC";
|
||||||
|
jakarta.persistence.TypedQuery<com.sisvietnamvn.web.domain.Page> q = entityManager.createQuery(jpql, com.sisvietnamvn.web.domain.Page.class);
|
||||||
|
if (group.tagIds() != null && !group.tagIds().isEmpty()) {
|
||||||
|
java.util.List<Long> tagIds = group.tagIds().stream().map(Number::longValue).collect(java.util.stream.Collectors.toList());
|
||||||
|
q.setParameter("tagIds", tagIds);
|
||||||
|
}
|
||||||
|
if (group.itemLimit() != null && group.itemLimit() > 0) q.setMaxResults(group.itemLimit());
|
||||||
|
List<com.sisvietnamvn.web.domain.Page> pages = q.getResultList();
|
||||||
|
for (com.sisvietnamvn.web.domain.Page p : pages) {
|
||||||
|
dynamicItems.add(new SwiperSliderAdminController.SliderItem(p.getTitle(), p.getHeroImage(), "/" + p.getSlug(), null, null, null, null));
|
||||||
|
}
|
||||||
|
} else if ("MEDIA".equals(group.dataSource())) {
|
||||||
|
String jpql = "SELECT m FROM Media m ";
|
||||||
|
if (group.mediaIds() != null && !group.mediaIds().isEmpty()) {
|
||||||
|
jpql += "WHERE m.id IN :mediaIds ";
|
||||||
|
}
|
||||||
|
jpql += "ORDER BY m.createdDate DESC";
|
||||||
|
jakarta.persistence.TypedQuery<com.sisvietnamvn.web.domain.Media> q = entityManager.createQuery(jpql, com.sisvietnamvn.web.domain.Media.class);
|
||||||
|
if (group.mediaIds() != null && !group.mediaIds().isEmpty()) {
|
||||||
|
java.util.List<Long> mediaIds = group.mediaIds().stream().map(Number::longValue).collect(java.util.stream.Collectors.toList());
|
||||||
|
q.setParameter("mediaIds", mediaIds);
|
||||||
|
}
|
||||||
|
if (group.itemLimit() != null && group.itemLimit() > 0) q.setMaxResults(group.itemLimit());
|
||||||
|
List<com.sisvietnamvn.web.domain.Media> mediaList = q.getResultList();
|
||||||
|
for (com.sisvietnamvn.web.domain.Media m : mediaList) {
|
||||||
|
dynamicItems.add(new SwiperSliderAdminController.SliderItem(m.getOriginalFilename(), m.getFileUrl(), m.getFileUrl(), null, null, null, null));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (group.items() != null) {
|
||||||
|
dynamicItems.addAll(group.items());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dynamicItems;
|
||||||
|
}
|
||||||
|
|
||||||
private String getSlideTemplateForSlug(String slug) {
|
private String getSlideTemplateForSlug(String slug) {
|
||||||
if ("gallery".equals(slug)) {
|
Optional<com.sisvietnamvn.web.domain.ComponentTemplate> optTpl = templateRepository.findBySlug("swiper-slide-" + slug);
|
||||||
return "<div class=\"swiper-slide max-w-[356px] !mr-2 lg:!mr-3 xl:!mr-4\" style=\"width: 356px; max-width: 100%;\"><div class=\"relative rounded-lg aspect-[3/2] overflow-hidden\"><img src=\"{{imageUrl}}\" alt=\"{{title}}\" class=\"absolute inset-0 w-full h-full object-cover\"></div></div>";
|
if (optTpl.isPresent()) {
|
||||||
}
|
return optTpl.get().getHtmlTemplate();
|
||||||
if ("chung-chi".equals(slug) || "giay-chung-nhan".equals(slug) || "thuc-hanh".equals(slug) || "xac-nhan-thuc-hanh".equals(slug)) {
|
|
||||||
return "<div class=\"swiper-slide h-auto max-w-[288px] mr-2 lg:!mr-3 xl:!mr-4\" style=\"width: 288px; max-width: 100%;\"><a href=\"{{linkUrl}}\" class=\"group h-full flex flex-col items-start gap-y-4 lg:gap-y-5 rounded-2xl bg-white p-4 pb-5 shadow-sm transition-shadow hover:shadow-md border border-transparent hover:border-primary-100\"><div class=\"relative w-full aspect-square overflow-hidden rounded-xl\"><img src=\"{{imageUrl}}\" alt=\"{{title}}\" class=\"absolute inset-0 size-full object-cover transition-transform duration-500 group-hover:scale-105\"></div><div class=\"flex flex-1 flex-col justify-between w-full\"><h3 class=\"heading-5 text-gray-900 group-hover:text-primary-600 transition-colors line-clamp-3\">{{title}}</h3></div></a></div>";
|
|
||||||
}
|
|
||||||
if ("hinh-anh-thuc-hanh".equals(slug)) {
|
|
||||||
return "<div class=\"swiper-slide max-w-[789px] !mr-2\" style=\"width: 789px; max-width: 100%;\"><div class=\"relative rounded-lg aspect-[789/460] overflow-hidden\"><img src=\"{{imageUrl}}\" alt=\"{{title}}\" class=\"absolute inset-0 w-full h-full object-cover\"></div></div>";
|
|
||||||
}
|
|
||||||
if ("course-card".equals(slug)) {
|
|
||||||
return "<div class=\"swiper-slide\" style=\"width: 261px; max-width: 100%;\"><article class=\"relative p-3 lg:p-4 bg-white rounded-lg border border-gray-100 group\" style=\"display: flex; flex-direction: column; gap: 14px; text-align: left;\"><a class=\"relative overflow-hidden rounded block\" style=\"aspect-ratio: 9/5;\" href=\"{{linkUrl}}\"><img alt=\"{{title}}\" class=\"object-cover h-full w-full\" src=\"{{imageUrl}}\"/><div class=\"absolute inset-0 w-full h-full bg-black/40 opacity-0 lg:group-hover:opacity-100 duration-300 ease-in-out\"></div></a><div class=\"px-1.5 w-full\" style=\"display: flex; flex-direction: column; gap: 14px;\"><div class=\"w-full space-y-1\"><a class=\"title-1 font-bold line-clamp-2 lg:group-hover:text-primary-600 duration-300 ease-in-out block\" style=\"font-size: 20px; height: 54px; line-height: 1.5;\" href=\"{{linkUrl}}\">{{title}}</a><div class=\"body-3 text-gray-700 line-clamp-3\" style=\"font-size: 14px; height: 64px; line-height: 1.5; overflow: hidden;\">{{description}}</div></div><div class=\"w-full h-px bg-gray-100\"></div><div class=\"flex justify-between items-center w-full\"><div class=\"flex gap-4 items-center self-stretch my-auto\"><div class=\"flex gap-1.5 items-center self-stretch my-auto\"><svg class=\"lucide lucide-calendar size-3.5\" fill=\"none\" height=\"14\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" viewBox=\"0 0 24 24\" width=\"14\" xmlns=\"http://www.w3.org/2000/svg\"><path d=\"M8 2v4\"></path><path d=\"M16 2v4\"></path><rect height=\"18\" rx=\"2\" width=\"18\" x=\"3\" y=\"4\"></rect><path d=\"M3 10h18\"></path></svg><time class=\"self-stretch body-3 my-auto\" style=\"font-size: 14px;\">{{date}}</time></div><span class=\"text-primary-600 font-bold border-b-2 border-primary-600\" style=\"font-size: 14px;\">({{badge}})</span></div></div></div><div class=\"btn\"><a class=\"p-2 lg:p-3 w-full text-white bg-primary-600 rounded lg:hover:bg-primary-300 duration-300 ease-in-out block mt-2\" href=\"{{linkUrl}}\"><div class=\"flex items-center gap-0.5 justify-center w-full font-bold\" style=\"font-size: 18px;\"><span>{{price}}</span></div></a></div></article></div>";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return "<div class=\"swiper-slide\"><div class=\"relative rounded-lg aspect-[3/2] overflow-hidden\"><img src=\"{{imageUrl}}\" alt=\"{{title}}\" class=\"absolute inset-0 w-full h-full object-cover\"></div></div>";
|
return "<div class=\"swiper-slide\"><div class=\"relative rounded-lg aspect-[3/2] overflow-hidden\"><img src=\"{{imageUrl}}\" alt=\"{{title}}\" class=\"absolute inset-0 w-full h-full object-cover\"></div></div>";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -303,31 +306,6 @@ public class SwiperSliderPlugin {
|
|||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
private void seedDefaultData() {
|
|
||||||
String json = settingService.getValue(SETTING_KEY, "");
|
|
||||||
if (json.isEmpty() || "[]".equals(json) || !json.contains("chung-chi") || json.contains("UMC_files")) {
|
|
||||||
String sampleData = "[{\"slug\":\"gallery\",\"name\":\"Gallery Đào Tạo\",\"items\":[" +
|
|
||||||
"{\"title\":\"Khóa đào tạo 1\",\"imageUrl\":\"/images/dao-tao/image-6.jpeg\",\"linkUrl\":\"\",\"description\":\"\"}," +
|
|
||||||
"{\"title\":\"Khóa đào tạo 2\",\"imageUrl\":\"/images/dao-tao/image-5.jpeg\",\"linkUrl\":\"\",\"description\":\"\"}," +
|
|
||||||
"{\"title\":\"Khóa đào tạo 3\",\"imageUrl\":\"/images/dao-tao/image-4.jpeg\",\"linkUrl\":\"\",\"description\":\"\"}" +
|
|
||||||
"]},{\"slug\":\"chung-chi\",\"name\":\"Cấp chứng chỉ\",\"items\":[" +
|
|
||||||
"{\"title\":\"Lớp chứng chỉ 1\",\"imageUrl\":\"/images/dao-tao/image-3.jpeg\",\"linkUrl\":\"\",\"description\":\"\"}," +
|
|
||||||
"{\"title\":\"Lớp chứng chỉ 2\",\"imageUrl\":\"/images/dao-tao/image-2.jpeg\",\"linkUrl\":\"\",\"description\":\"\"}," +
|
|
||||||
"{\"title\":\"Lớp chứng chỉ 3\",\"imageUrl\":\"/images/dao-tao/image-1.jpeg\",\"linkUrl\":\"\",\"description\":\"\"}" +
|
|
||||||
"]},{\"slug\":\"giay-chung-nhan\",\"name\":\"Cấp giấy chứng nhận\",\"items\":[" +
|
|
||||||
"{\"title\":\"Giấy chứng nhận 1\",\"imageUrl\":\"/images/dao-tao/training-4.jpeg\",\"linkUrl\":\"\",\"description\":\"\"}," +
|
|
||||||
"{\"title\":\"Giấy chứng nhận 2\",\"imageUrl\":\"/images/dao-tao/training-3.jpeg\",\"linkUrl\":\"\",\"description\":\"\"}" +
|
|
||||||
"]},{\"slug\":\"xac-nhan-thuc-hanh\",\"name\":\"Đào tạo cấp giấy xác nhận quá trình thực hành\",\"items\":[" +
|
|
||||||
"{\"title\":\"Xác nhận thực hành 1\",\"imageUrl\":\"/images/dao-tao/PTNS2707.jpeg\",\"linkUrl\":\"\",\"description\":\"\"}," +
|
|
||||||
"{\"title\":\"Xác nhận thực hành 2\",\"imageUrl\":\"/images/dao-tao/training-2.jpeg\",\"linkUrl\":\"\",\"description\":\"\"}" +
|
|
||||||
"]},{\"slug\":\"hinh-anh-thuc-hanh\",\"name\":\"Hình ảnh thực hành lâm sàng\",\"items\":[" +
|
|
||||||
"{\"title\":\"Thực hành lâm sàng 1\",\"imageUrl\":\"/images/dao-tao/training-1.jpeg\",\"linkUrl\":\"\",\"description\":\"\"}," +
|
|
||||||
"{\"title\":\"Thực hành lâm sàng 2\",\"imageUrl\":\"/images/dao-tao/training-2.jpeg\",\"linkUrl\":\"\",\"description\":\"\"}" +
|
|
||||||
"]}]";
|
|
||||||
settingService.setValue(SETTING_KEY, sampleData);
|
|
||||||
LOG.info("Seeded default swiper slider data for all 5 training sliders");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void seedComponentTemplate() {
|
private void seedComponentTemplate() {
|
||||||
java.util.Optional<com.sisvietnamvn.web.domain.ComponentTemplate> opt = templateRepository.findBySlug("swiper-slider");
|
java.util.Optional<com.sisvietnamvn.web.domain.ComponentTemplate> opt = templateRepository.findBySlug("swiper-slider");
|
||||||
@@ -341,6 +319,25 @@ public class SwiperSliderPlugin {
|
|||||||
templateRepository.save(t);
|
templateRepository.save(t);
|
||||||
LOG.info("Seeded default component template: swiper-slider");
|
LOG.info("Seeded default component template: swiper-slider");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
seedSlideTemplate("swiper-slide-gallery", "Swiper Slide (Gallery)", "<div class=\"swiper-slide max-w-[356px] !mr-2 lg:!mr-3 xl:!mr-4\" style=\"width: 356px; max-width: 100%;\"><div class=\"relative rounded-lg aspect-[3/2] overflow-hidden\"><img src=\"{{imageUrl}}\" alt=\"{{title}}\" class=\"absolute inset-0 w-full h-full object-cover\"></div></div>");
|
||||||
|
seedSlideTemplate("swiper-slide-chung-chi", "Swiper Slide (Chứng chỉ/Thẻ)", "<div class=\"swiper-slide h-auto max-w-[288px] mr-2 lg:!mr-3 xl:!mr-4\" style=\"width: 288px; max-width: 100%;\"><a href=\"{{linkUrl}}\" class=\"group h-full flex flex-col items-start gap-y-4 lg:gap-y-5 rounded-2xl bg-white p-4 pb-5 shadow-sm transition-shadow hover:shadow-md border border-transparent hover:border-primary-100\"><div class=\"relative w-full aspect-square overflow-hidden rounded-xl\"><img src=\"{{imageUrl}}\" alt=\"{{title}}\" class=\"absolute aspect-square inset-0 size-full object-cover transition-transform duration-500 group-hover:scale-105\"></div><div class=\"flex flex-1 flex-col justify-between w-full\"><h3 class=\"heading-5 text-gray-900 group-hover:text-primary-600 transition-colors line-clamp-3\">{{title}}</h3></div></a></div>");
|
||||||
|
seedSlideTemplate("swiper-slide-hinh-anh-thuc-hanh", "Swiper Slide (Hình ảnh lớn)", "<div class=\"swiper-slide max-w-[789px] !mr-2\" style=\"width: 789px; max-width: 100%;\"><div class=\"relative rounded-lg aspect-[789/460] overflow-hidden\"><img src=\"{{imageUrl}}\" alt=\"{{title}}\" class=\"absolute inset-0 w-full h-full object-cover\"></div></div>");
|
||||||
|
seedSlideTemplate("swiper-slide-course-card", "Swiper Slide (Course Card)", "<div class=\"swiper-slide\" style=\"width: 261px; max-width: 100%;\"><article class=\"relative p-3 lg:p-4 bg-white rounded-lg border border-gray-100 group\" style=\"display: flex; flex-direction: column; gap: 14px; text-align: left;\"><a class=\"relative overflow-hidden rounded block\" style=\"aspect-ratio: 9/5;\" href=\"{{linkUrl}}\"><img alt=\"{{title}}\" class=\"object-cover h-full w-full\" src=\"{{imageUrl}}\"/><div class=\"absolute inset-0 w-full h-full bg-black/40 opacity-0 lg:group-hover:opacity-100 duration-300 ease-in-out\"></div></a><div class=\"px-1.5 w-full\" style=\"display: flex; flex-direction: column; gap: 14px;\"><div class=\"w-full space-y-1\"><a class=\"title-1 font-bold line-clamp-2 lg:group-hover:text-primary-600 duration-300 ease-in-out block\" style=\"font-size: 20px; height: 54px; line-height: 1.5;\" href=\"{{linkUrl}}\">{{title}}</a><div class=\"body-3 text-gray-700 line-clamp-3\" style=\"font-size: 14px; height: 64px; line-height: 1.5; overflow: hidden;\">{{description}}</div></div><div class=\"w-full h-px bg-gray-100\"></div><div class=\"flex justify-between items-center w-full\"><div class=\"flex gap-4 items-center self-stretch my-auto\"><div class=\"flex gap-1.5 items-center self-stretch my-auto\"><svg class=\"lucide lucide-calendar size-3.5\" fill=\"none\" height=\"14\" stroke=\"currentColor\" stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" viewBox=\"0 0 24 24\" width=\"14\" xmlns=\"http://www.w3.org/2000/svg\"><path d=\"M8 2v4\"></path><path d=\"M16 2v4\"></path><rect height=\"18\" rx=\"2\" width=\"18\" x=\"3\" y=\"4\"></rect><path d=\"M3 10h18\"></path></svg><time class=\"self-stretch body-3 my-auto\" style=\"font-size: 14px;\">{{date}}</time></div><span class=\"text-primary-600 font-bold border-b-2 border-primary-600\" style=\"font-size: 14px;\">({{badge}})</span></div></div></div><div class=\"btn\"><a class=\"p-2 lg:p-3 w-full text-white bg-primary-600 rounded lg:hover:bg-primary-300 duration-300 ease-in-out block mt-2\" href=\"{{linkUrl}}\"><div class=\"flex items-center gap-0.5 justify-center w-full font-bold\" style=\"font-size: 18px;\"><span>{{price}}</span></div></a></div></article></div>");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void seedSlideTemplate(String slug, String name, String html) {
|
||||||
|
java.util.Optional<com.sisvietnamvn.web.domain.ComponentTemplate> opt = templateRepository.findBySlug(slug);
|
||||||
|
if (opt.isEmpty()) {
|
||||||
|
com.sisvietnamvn.web.domain.ComponentTemplate t = new com.sisvietnamvn.web.domain.ComponentTemplate();
|
||||||
|
t.setSlug(slug);
|
||||||
|
t.setName(name);
|
||||||
|
t.setDescription("Template for " + name);
|
||||||
|
t.setActive(true);
|
||||||
|
t.setHtmlTemplate(html);
|
||||||
|
templateRepository.save(t);
|
||||||
|
LOG.info("Seeded default component template: {}", slug);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private String getDefaultTemplate() {
|
private String getDefaultTemplate() {
|
||||||
@@ -352,7 +349,7 @@ public class SwiperSliderPlugin {
|
|||||||
<div class="swiper swiper-{{slug}} w-full swiper-backface-hidden">
|
<div class="swiper swiper-{{slug}} w-full swiper-backface-hidden">
|
||||||
<div class="swiper-wrapper">
|
<div class="swiper-wrapper">
|
||||||
{{#each items}}
|
{{#each items}}
|
||||||
<div class="swiper-slide max-w-[356px] mr-2 lg:!mr-3 xl:!mr-4" style="width: 356px;">
|
<div class="swiper-slide max-w-[356px] border-gray-1 mr-2 lg:!mr-3 xl:!mr-4" style="width: 356px;">
|
||||||
<div class="relative rounded-lg aspect-[3/2] overflow-hidden">
|
<div class="relative rounded-lg aspect-[3/2] overflow-hidden">
|
||||||
<img src="{{imageUrl}}" alt="{{title}}" class="absolute inset-0 w-full h-full object-cover">
|
<img src="{{imageUrl}}" alt="{{title}}" class="absolute inset-0 w-full h-full object-cover">
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -28,6 +28,11 @@ public interface PageRepository extends JpaRepository<Page, Long> {
|
|||||||
*/
|
*/
|
||||||
Optional<Page> findByPageType(com.sisvietnamvn.web.domain.PageType pageType);
|
Optional<Page> findByPageType(com.sisvietnamvn.web.domain.PageType pageType);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the first page matching a predefined CMS Page Type (prevents NonUniqueResultException).
|
||||||
|
*/
|
||||||
|
Optional<Page> findFirstByPageTypeOrderByIdAsc(com.sisvietnamvn.web.domain.PageType pageType);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Find all pages with a given publication status.
|
* Find all pages with a given publication status.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ public class MediaService {
|
|||||||
Media media = new Media();
|
Media media = new Media();
|
||||||
media.setFileUrl(fileUrl);
|
media.setFileUrl(fileUrl);
|
||||||
media.setOriginalFilename(filename);
|
media.setOriginalFilename(filename);
|
||||||
|
media.setStoredFilename(filename);
|
||||||
media.setMediaType(MediaType.IMAGE); // Assume image for now
|
media.setMediaType(MediaType.IMAGE); // Assume image for now
|
||||||
|
|
||||||
mediaRepository.save(media);
|
mediaRepository.save(media);
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ public class PageService {
|
|||||||
@Transactional(readOnly = true)
|
@Transactional(readOnly = true)
|
||||||
public Optional<Page> findByPageType(com.sisvietnamvn.web.domain.PageType pageType) {
|
public Optional<Page> findByPageType(com.sisvietnamvn.web.domain.PageType pageType) {
|
||||||
LOG.debug("Request to get Page by type : {}", pageType);
|
LOG.debug("Request to get Page by type : {}", pageType);
|
||||||
return pageRepository.findByPageType(pageType);
|
return pageRepository.findFirstByPageTypeOrderByIdAsc(pageType);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<databaseChangeLog
|
||||||
|
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||||
|
xmlns:ext="http://www.liquibase.org/xml/ns/dbchangelog-ext"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-latest.xsd
|
||||||
|
http://www.liquibase.org/xml/ns/dbchangelog-ext http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-ext.xsd">
|
||||||
|
|
||||||
|
<changeSet id="20260729104200-1" author="antigravity">
|
||||||
|
<createTable tableName="sis_page_tag">
|
||||||
|
<column name="page_id" type="bigint">
|
||||||
|
<constraints nullable="false"/>
|
||||||
|
</column>
|
||||||
|
<column name="tag_id" type="bigint">
|
||||||
|
<constraints nullable="false"/>
|
||||||
|
</column>
|
||||||
|
</createTable>
|
||||||
|
|
||||||
|
<addPrimaryKey columnNames="page_id, tag_id" tableName="sis_page_tag"/>
|
||||||
|
|
||||||
|
<addForeignKeyConstraint baseColumnNames="page_id"
|
||||||
|
baseTableName="sis_page_tag"
|
||||||
|
constraintName="fk_page_tag_page_id"
|
||||||
|
referencedColumnNames="id"
|
||||||
|
referencedTableName="sis_page"/>
|
||||||
|
|
||||||
|
<addForeignKeyConstraint baseColumnNames="tag_id"
|
||||||
|
baseTableName="sis_page_tag"
|
||||||
|
constraintName="fk_page_tag_tag_id"
|
||||||
|
referencedColumnNames="id"
|
||||||
|
referencedTableName="sis_tag"/>
|
||||||
|
</changeSet>
|
||||||
|
</databaseChangeLog>
|
||||||
@@ -57,4 +57,5 @@
|
|||||||
<include file="config/liquibase/changelog/20260728165000_map_specialties.xml" relativeToChangelogFile="false"/>
|
<include file="config/liquibase/changelog/20260728165000_map_specialties.xml" relativeToChangelogFile="false"/>
|
||||||
<include file="config/liquibase/changelog/20260728166000_update_active_doctors.xml" relativeToChangelogFile="false"/>
|
<include file="config/liquibase/changelog/20260728166000_update_active_doctors.xml" relativeToChangelogFile="false"/>
|
||||||
<include file="config/liquibase/changelog/20260728173000_fix_xml_specialties.xml" relativeToChangelogFile="false"/>
|
<include file="config/liquibase/changelog/20260728173000_fix_xml_specialties.xml" relativeToChangelogFile="false"/>
|
||||||
|
<include file="config/liquibase/changelog/20260729104200_add_page_tag.xml" relativeToChangelogFile="false"/>
|
||||||
</databaseChangeLog>
|
</databaseChangeLog>
|
||||||
|
|||||||
@@ -1192,6 +1192,54 @@ figure.table table tr:hover {
|
|||||||
background-color: #f5f5f5;
|
background-color: #f5f5f5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Container spacing */
|
||||||
|
#selected_categoryIds, #selected_tagIds {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark Red Pill Badge */
|
||||||
|
.category-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
background-color: #8b0000; /* Dark maroon / red background */
|
||||||
|
color: #ffffff; /* White text */
|
||||||
|
padding: 4px 6px 4px 12px;
|
||||||
|
border-radius: 12px; /* Pill shape */
|
||||||
|
font-size: 13px;
|
||||||
|
font-family: sans-serif;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Text spacing inside pill */
|
||||||
|
.badge-text {
|
||||||
|
margin-right: 6px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Boxed 'x' button inside pill */
|
||||||
|
.remove_category-badge {
|
||||||
|
background-color: #ffffff; /* White box background */
|
||||||
|
color: #000000; /* Dark x text */
|
||||||
|
border: 1px solid #333333; /* Dark outline */
|
||||||
|
border-radius: 2px; /* Slightly rounded box */
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: bold;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 1px 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remove_category-badge:hover {
|
||||||
|
background-color: #f0f0f0;
|
||||||
|
color: #ff0000;
|
||||||
|
}
|
||||||
|
|
||||||
.training-layout table tr:first-child td,
|
.training-layout table tr:first-child td,
|
||||||
.training-layout table th {
|
.training-layout table th {
|
||||||
background-color: var(--color-old-brick, #881c1c) !important;
|
background-color: var(--color-old-brick, #881c1c) !important;
|
||||||
@@ -1219,10 +1267,17 @@ figure.table table tr:hover {
|
|||||||
|
|
||||||
.flex {
|
.flex {
|
||||||
display: flex !important;
|
display: flex !important;
|
||||||
|
.h-stretch {
|
||||||
|
height: stretch;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.flex-col {
|
.flex-col {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
.flex-row {
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
|
||||||
.items-center {
|
.items-center {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
@@ -1270,10 +1325,13 @@ figure.table table tr:hover {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.swiper-dao-tao-02 a {
|
||||||
|
background-color: var(--color-white);
|
||||||
|
}
|
||||||
|
|
||||||
.swiper-slide {
|
.swiper-slide {
|
||||||
border: solid 1px #efeff0;
|
|
||||||
border-radius: 15px;
|
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
|
height: stretch;
|
||||||
.btn {
|
.btn {
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
border-radius: 15px;
|
border-radius: 15px;
|
||||||
@@ -1287,3 +1345,91 @@ figure.table table tr:hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.border-gray-1 {
|
||||||
|
border: 1px solid rgb(239, 239, 240);
|
||||||
|
border-radius: 15px;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* CSS cho Component Carousel */
|
||||||
|
.course-swiper-component {
|
||||||
|
position: relative;
|
||||||
|
padding: 10px 24px; /* Chừa không gian 2 bên cho nút bấm nhô ra */
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.course-swiper-component .swiper {
|
||||||
|
width: 100%;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Đảm bảo chiều cao các Slide bằng nhau */
|
||||||
|
.course-swiper-component .swiper-slide {
|
||||||
|
height: auto;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom Nút bấm hình tròn trắng nổi 2 bên mép */
|
||||||
|
.course-swiper-component .swiper-button-prev,
|
||||||
|
.course-swiper-component .swiper-button-next {
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
background-color: #ffffff;
|
||||||
|
border-radius: 50%;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
color: #333333;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.course-swiper-component .swiper-button-prev { left: 0; }
|
||||||
|
.course-swiper-component .swiper-button-next { right: 0; }
|
||||||
|
|
||||||
|
.course-swiper-component .swiper-button-prev::after,
|
||||||
|
.course-swiper-component .swiper-button-next::after {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.course-swiper-component .swiper-button-prev:hover,
|
||||||
|
.course-swiper-component .swiper-button-next:hover {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
color: #9b1b1b;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ép hiển thị nút tròn < và > */
|
||||||
|
.swiper-button-prev,
|
||||||
|
.swiper-button-next {
|
||||||
|
width: 44px !important;
|
||||||
|
height: 44px !important;
|
||||||
|
background-color: #ffffff !important; /* Nền trắng */
|
||||||
|
border-radius: 50% !important; /* Bo tròn */
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25) !important; /* Đổ bóng */
|
||||||
|
color: #111111 !important; /* Màu mũi tên đen */
|
||||||
|
|
||||||
|
display: flex !important;
|
||||||
|
align-items: center !important;
|
||||||
|
justify-content: center !important;
|
||||||
|
|
||||||
|
font-size: 22px !important; /* Kích thước chữ < và > */
|
||||||
|
font-weight: bold !important;
|
||||||
|
z-index: 50 !important; /* Đảm bảo nổi lên trên các card */
|
||||||
|
cursor: pointer !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Xóa icon font mặc định của Swiper để không bị đè chữ */
|
||||||
|
.swiper-button-prev::after,
|
||||||
|
.swiper-button-next::after {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hiệu ứng khi di chuột vào nút */
|
||||||
|
.swiper-button-prev:hover,
|
||||||
|
.swiper-button-next:hover {
|
||||||
|
background-color: #f1f1f1 !important;
|
||||||
|
color: #9b1b1b !important;
|
||||||
|
transform: scale(1.08);
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
/**
|
||||||
|
* Main JavaScript for SIS Vietnam
|
||||||
|
* Automatically loads and initializes global components like Swiper sliders
|
||||||
|
*/
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
initGlobalSwipers();
|
||||||
|
|
||||||
|
const courseSwiper = new Swiper('.courseSwiper', {
|
||||||
|
loop: true,
|
||||||
|
spaceBetween: 20, // Khoảng cách giữa các card
|
||||||
|
autoplay: {
|
||||||
|
delay: 3500, // Tự động trượt sau 3.5 giây
|
||||||
|
disableOnInteraction: false,
|
||||||
|
pauseOnMouseEnter: true,
|
||||||
|
},
|
||||||
|
navigation: {
|
||||||
|
nextEl: '.course-swiper-component .swiper-button-next',
|
||||||
|
prevEl: '.course-swiper-component .swiper-button-prev',
|
||||||
|
},
|
||||||
|
// Breakpoints chuẩn Responsive
|
||||||
|
breakpoints: {
|
||||||
|
320: { slidesPerView: 1 }, // Điện thoại
|
||||||
|
640: { slidesPerView: 2 }, // Tablet nhỏ
|
||||||
|
1024: { slidesPerView: 3 }, // Laptop
|
||||||
|
1280: { slidesPerView: 4 } // Màn hình rộng
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function initGlobalSwipers() {
|
||||||
|
const swipers = document.querySelectorAll('.swiper');
|
||||||
|
|
||||||
|
swipers.forEach(function(el) {
|
||||||
|
// Skip if already initialized by another script
|
||||||
|
if (el.swiper) return;
|
||||||
|
|
||||||
|
// Try to find navigation buttons in the parent container
|
||||||
|
const parent = el.parentElement;
|
||||||
|
|
||||||
|
// Look for standard swiper buttons or custom ones (e.g. .btn-...-prev)
|
||||||
|
const prevBtn = parent.querySelector('.swiper-button-prev') || parent.querySelector('[class*="-prev"]');
|
||||||
|
const nextBtn = parent.querySelector('.swiper-button-next') || parent.querySelector('[class*="-next"]');
|
||||||
|
const pagination = parent.querySelector('.swiper-pagination');
|
||||||
|
|
||||||
|
new Swiper(el, {
|
||||||
|
slidesPerView: 'auto',
|
||||||
|
spaceBetween: 16,
|
||||||
|
observer: true,
|
||||||
|
observeParents: true,
|
||||||
|
watchOverflow: false,
|
||||||
|
rewind: true,
|
||||||
|
navigation: {
|
||||||
|
nextEl: nextBtn,
|
||||||
|
prevEl: prevBtn,
|
||||||
|
},
|
||||||
|
pagination: pagination ? {
|
||||||
|
el: pagination,
|
||||||
|
clickable: true,
|
||||||
|
} : false
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -45,7 +45,7 @@
|
|||||||
<!--$-->
|
<!--$-->
|
||||||
<!--/$-->
|
<!--/$-->
|
||||||
<main class="">
|
<main class="">
|
||||||
<section class="overflow-hidden relative w-full bg-white xl:h-[580px] md:h-[450px] h-[250px] max-h-[calc(100vh-122px)]">
|
<section class="overflow-hidden relative w-full bg-white xl:h-[580px] md:h-[450px] h-[250px] max-h-[calc(100vh-122px)] bg-[#f6f6f6]">
|
||||||
<div class="absolute inset-0">
|
<div class="absolute inset-0">
|
||||||
<img alt="banner (1)" fetchpriority="high" loading="eager" decoding="async" data-nimg="fill" class="max-md:hidden" style="position:absolute;height:100%;width:100%;left:0;top:0;right:0;bottom:0;object-fit:cover;color:transparent" th:src="@{/images/dao-tao/banner (1).jpeg}">
|
<img alt="banner (1)" fetchpriority="high" loading="eager" decoding="async" data-nimg="fill" class="max-md:hidden" style="position:absolute;height:100%;width:100%;left:0;top:0;right:0;bottom:0;object-fit:cover;color:transparent" th:src="@{/images/dao-tao/banner (1).jpeg}">
|
||||||
<img alt="banner (1)" fetchpriority="high" loading="eager" decoding="async" data-nimg="fill" class="md:hidden" style="position:absolute;height:100%;width:100%;left:0;top:0;right:0;bottom:0;object-fit:cover;color:transparent" th:src="@{/images/dao-tao/banner (1).jpeg}">
|
<img alt="banner (1)" fetchpriority="high" loading="eager" decoding="async" data-nimg="fill" class="md:hidden" style="position:absolute;height:100%;width:100%;left:0;top:0;right:0;bottom:0;object-fit:cover;color:transparent" th:src="@{/images/dao-tao/banner (1).jpeg}">
|
||||||
@@ -61,8 +61,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section class="xl:py-8 md:py-6 py-4">
|
<section class="xl:py-8 md:py-6 py-4 bg-white">
|
||||||
<div class="container xl:space-y-8 md:space-y-6 space-y-4">
|
<div class="container xl:space-y-8 md:space-y-6 space-y-4 bg-[#f6f6f6]">
|
||||||
<div class="prose prose-content">
|
<div class="prose prose-content">
|
||||||
<div style="-webkit-text-stroke-width:0px;border-style:solid;border-width:0px;box-sizing:border-box;color:rgb(51, 51, 51);font-family:Roboto, ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";font-size:16px;font-style:normal;font-variant-caps:normal;font-variant-ligatures:normal;font-weight:400;letter-spacing:normal;margin:0px 0px 12px;orphans:2;padding:0px;text-align:justify;text-decoration-color:initial;text-decoration-style:initial;text-decoration-thickness:initial;text-indent:0px;text-transform:none;white-space:normal;widows:2;word-spacing:0px;">Với chức năng là cơ sở giảng dạy thực hành, đào tạo là một trong những hoạt động nòng cốt của Bệnh viện Đại học Y Dược TP. Hồ Chí Minh, nhằm tối ưu công tác đào tạo, Bệnh viện đa dạng hóa các loại hình đào tạo để đáp ứng các yêu cầu về đào tạo của nội bộ, của các cơ sở y tế khác và của học viên nước ngoài.Với nguồn nhân lực dồi dào và trình độ chuyên môn cao, các chuyên khoa lâm sàng và cận lâm sàng lớn mạnh, số lượng người bệnh đến khám và chữa bệnh tăng cao, Bệnh viện Đại học Y Dược TP. Hồ Chí Minh còn là nơi thực hành không những của nhân viên y tế Việt Nam, mà còn là sự lựa chọn của rất nhiều sinh viên các nước như: Nhật Bản, Úc, Hoa Kỳ, Thái Lan, CHLB Đức, Pháp, Bỉ, Áo, Đan Mạch, Ấn Độ, …</div>
|
<div style="-webkit-text-stroke-width:0px;border-style:solid;border-width:0px;box-sizing:border-box;color:rgb(51, 51, 51);font-family:Roboto, ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";font-size:16px;font-style:normal;font-variant-caps:normal;font-variant-ligatures:normal;font-weight:400;letter-spacing:normal;margin:0px 0px 12px;orphans:2;padding:0px;text-align:justify;text-decoration-color:initial;text-decoration-style:initial;text-decoration-thickness:initial;text-indent:0px;text-transform:none;white-space:normal;widows:2;word-spacing:0px;">Với chức năng là cơ sở giảng dạy thực hành, đào tạo là một trong những hoạt động nòng cốt của Bệnh viện Đại học Y Dược TP. Hồ Chí Minh, nhằm tối ưu công tác đào tạo, Bệnh viện đa dạng hóa các loại hình đào tạo để đáp ứng các yêu cầu về đào tạo của nội bộ, của các cơ sở y tế khác và của học viên nước ngoài.Với nguồn nhân lực dồi dào và trình độ chuyên môn cao, các chuyên khoa lâm sàng và cận lâm sàng lớn mạnh, số lượng người bệnh đến khám và chữa bệnh tăng cao, Bệnh viện Đại học Y Dược TP. Hồ Chí Minh còn là nơi thực hành không những của nhân viên y tế Việt Nam, mà còn là sự lựa chọn của rất nhiều sinh viên các nước như: Nhật Bản, Úc, Hoa Kỳ, Thái Lan, CHLB Đức, Pháp, Bỉ, Áo, Đan Mạch, Ấn Độ, …</div>
|
||||||
<div style="-webkit-text-stroke-width:0px;border-style:solid;border-width:0px;box-sizing:border-box;color:rgb(51, 51, 51);font-family:Roboto, ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";font-size:16px;font-style:normal;font-variant-caps:normal;font-variant-ligatures:normal;font-weight:400;letter-spacing:normal;margin:0px 0px 12px;orphans:2;padding:0px;text-align:justify;text-decoration-color:initial;text-decoration-style:initial;text-decoration-thickness:initial;text-indent:0px;text-transform:none;white-space:normal;widows:2;word-spacing:0px;">Ngoài ra, hình thức đào tạo theo hợp đồng/đào tạo theo nhiệm vụ chỉ đạo tuyến/đào tạo chuyển giao kỹ thuật và các khóa đào tạo chuyên môn nghiệp vụ khác của ngành y tế mà không thuộc hệ thống bằng cấp quốc gia cũng là một nhiệm vụ của Bệnh viện Đại học Y Dược TP. Hồ Chí Minh.</div>
|
<div style="-webkit-text-stroke-width:0px;border-style:solid;border-width:0px;box-sizing:border-box;color:rgb(51, 51, 51);font-family:Roboto, ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";font-size:16px;font-style:normal;font-variant-caps:normal;font-variant-ligatures:normal;font-weight:400;letter-spacing:normal;margin:0px 0px 12px;orphans:2;padding:0px;text-align:justify;text-decoration-color:initial;text-decoration-style:initial;text-decoration-thickness:initial;text-indent:0px;text-transform:none;white-space:normal;widows:2;word-spacing:0px;">Ngoài ra, hình thức đào tạo theo hợp đồng/đào tạo theo nhiệm vụ chỉ đạo tuyến/đào tạo chuyển giao kỹ thuật và các khóa đào tạo chuyên môn nghiệp vụ khác của ngành y tế mà không thuộc hệ thống bằng cấp quốc gia cũng là một nhiệm vụ của Bệnh viện Đại học Y Dược TP. Hồ Chí Minh.</div>
|
||||||
@@ -100,20 +100,25 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section class="xl:py-12 md:py-8 py-6 bg-[#f6f6f6] xl:space-y-12 md:space-y-8 space-y-6">
|
<section class="xl:py-12 md:py-8 py-6 xl:space-y-12 md:space-y-8 space-y-6">
|
||||||
<div class="lg:container max-lg:pl-5 max-md:px-0" th:if="${daoTaoPosts != null and not #lists.isEmpty(daoTaoPosts)}">
|
<div class="lg:container max-lg:pl-5 max-md:px-0 bg-white" th:if="${daoTaoPosts != null and not #lists.isEmpty(daoTaoPosts)}">
|
||||||
<div class="flex justify-between items-end md:mb-6 mb-4 lg:mb-8 max-lg:pr-5 max-md:px-4">
|
<div class="md:mb-6 mb-4 lg:mb-8 max-lg:pr-5 max-md:px-4">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="display-7 text-primary-600">Khóa học Đào tạo</h2>
|
<h2 class="display-7 text-primary-600">Khóa học Đào tạo</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-4">
|
<div>
|
||||||
<div class="flex md:gap-2 gap-1">
|
<div class="relative md:flex md:items-center md:gap-x-2">
|
||||||
<button class="btn-navigation-khoa-hoc-prev md:size-10 size-8 flex items-center justify-center rounded-full bg-white shadow-sm border border-gray-100 hover:bg-primary-600 hover:text-white transition-colors cursor-pointer group">
|
<button class="btn-navigation-dao-tao-02-prev md:size-10 size-8 flex items-center justify-center rounded-full bg-white shadow-sm border border-gray-100 hover:bg-primary-600 hover:text-white transition-colors cursor-pointer group">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevron-up size-4 -rotate-90">
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevron-up size-4 -rotate-90">
|
||||||
<path d="m18 15-6-6-6 6"></path>
|
<path d="m18 15-6-6-6 6"></path>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<button class="btn-navigation-khoa-hoc-next md:size-10 size-8 flex items-center justify-center rounded-full bg-white shadow-sm border border-gray-100 hover:bg-primary-600 hover:text-white transition-colors cursor-pointer group">
|
<div class="swiper swiper-dao-tao-02 w-full">
|
||||||
|
<div class="swiper-wrapper">
|
||||||
|
<th:block th:utext="${hookManager.applyFilters('swiper_slider_items', '', 'dao-tao-02')}"></th:block>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="btn-navigation-dao-tao-02-next md:size-10 size-8 flex items-center justify-center rounded-full bg-white shadow-sm border border-gray-100 hover:bg-primary-600 hover:text-white transition-colors cursor-pointer group">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevron-down size-4 -rotate-90">
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevron-down size-4 -rotate-90">
|
||||||
<path d="m6 9 6 6 6-6"></path>
|
<path d="m6 9 6 6 6-6"></path>
|
||||||
</svg>
|
</svg>
|
||||||
@@ -121,55 +126,24 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="relative max-md:px-4 md:mb-6 mb-4 lg:mb-8">
|
|
||||||
<div class="swiper swiper-khoa-hoc w-full">
|
|
||||||
<!-- 1. ĐÃ XÓA gap-3 -->
|
|
||||||
<div class="swiper-wrapper" style="height: 450px;">
|
|
||||||
<th:block th:each="post : ${daoTaoPosts}">
|
|
||||||
<!-- 2. Dùng h-auto để Swiper tự động stretch các card cao bằng nhau -->
|
|
||||||
<div class="swiper-slide h-auto" style="width: 288px; max-width: 100%; margin: 5px; background-color: var(--color-white);">
|
|
||||||
|
|
||||||
<!-- Thẻ a (card) đã set h-full để giãn kịch trần -->
|
|
||||||
<a th:href="@{/dao-tao/{slug}(slug=${post.slug})}" class="group h-full flex flex-col items-start">
|
|
||||||
|
|
||||||
<!-- 3. Đã thêm overflow-hidden vào khung ảnh -->
|
|
||||||
<div class="w-full rounded-xl mb-3">
|
|
||||||
<img th:src="${post.imageUrl != null and !#strings.isEmpty(post.imageUrl) ? post.imageUrl : '/images/dao-tao/training-1.jpeg'}"
|
|
||||||
th:alt="${post.title}"
|
|
||||||
class="size-full aspect-square object-cover transition-transform duration-500 group-hover:scale-105">
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="lg:container max-lg:pl-5 max-md:px-0 bg-[#f6f6f6]">
|
||||||
<!-- 4. Gom Tiêu đề và Trích dẫn vào chung 1 thẻ flex-1 -->
|
<div class="xl:py-12 md:py-8 py-6 xl:space-y-12 md:space-y-8 space-y-6">
|
||||||
<div class="flex flex-1 flex-col justify-start w-full gap-y-2">
|
|
||||||
<h3 class="heading-5 text-gray-900 group-hover:text-primary-600 transition-colors line-clamp-3"
|
|
||||||
th:text="${post.title}">Tiêu đề khóa học</h3>
|
|
||||||
|
|
||||||
<!-- Đoạn text trích dẫn -->
|
|
||||||
<p class="body-3 text-gray-600 line-clamp-3"
|
|
||||||
th:text="${post.excerpt}">Giới thiệu ngắn gọn</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</th:block>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
<div class="lg:container max-lg:pl-5 max-md:px-0">
|
|
||||||
<div class="flex justify-between items-end md:mb-6 mb-4 lg:mb-8 max-lg:pr-5 max-md:px-4">
|
|
||||||
<div>
|
<div>
|
||||||
<h2 class="display-7 text-primary-600">Cấp chứng chỉ</h2>
|
<h2 class="display-7 text-primary-600">Cấp chứng chỉ</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex md:gap-2 gap-1 items-center">
|
||||||
<div class="flex md:gap-2 gap-1">
|
<button class="btn-navigation-chung-chi-01-prev md:size-10 size-8 flex items-center justify-center rounded-full bg-white shadow-sm border border-gray-100 hover:bg-primary-600 hover:text-white transition-colors cursor-pointer group">
|
||||||
<button class="btn-navigation-cap-chung-chi-prev md:size-10 size-8 flex items-center justify-center rounded-full bg-white shadow-sm border border-gray-100 hover:bg-primary-600 hover:text-white transition-colors cursor-pointer group">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevron-up size-4 -rotate-90">
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevron-up size-4 -rotate-90">
|
||||||
<path d="m18 15-6-6-6 6"></path>
|
<path d="m18 15-6-6-6 6"></path>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<button class="btn-navigation-cap-chung-chi-next md:size-10 size-8 flex items-center justify-center rounded-full bg-white shadow-sm border border-gray-100 hover:bg-primary-600 hover:text-white transition-colors cursor-pointer group">
|
<div class="swiper swiper-chung-chi-01 w-full">
|
||||||
|
<div class="swiper-wrapper">
|
||||||
|
<th:block th:utext="${hookManager.applyFilters('swiper_slider_items', '', 'chung-chi-01')}"></th:block>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="btn-navigation-chung-chi-01-next md:size-10 size-8 flex items-center justify-center rounded-full bg-white shadow-sm border border-gray-100 hover:bg-primary-600 hover:text-white transition-colors cursor-pointer group">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevron-down size-4 -rotate-90">
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevron-down size-4 -rotate-90">
|
||||||
<path d="m6 9 6 6 6-6"></path>
|
<path d="m6 9 6 6 6-6"></path>
|
||||||
</svg>
|
</svg>
|
||||||
@@ -177,27 +151,23 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="relative max-md:px-4 md:mb-6 mb-4 lg:mb-8">
|
<div class="lg:container max-lg:pl-5 max-md:px-0 bg-white">
|
||||||
<div class="swiper swiper-chung-chi w-full">
|
<div>
|
||||||
<div class="swiper-wrapper">
|
|
||||||
<th:block th:utext="${hookManager.applyFilters('swiper_slider_items', '', 'chung-chi')}"></th:block>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="lg:container max-lg:pl-5 max-md:px-0">
|
|
||||||
<div class="flex justify-between items-end md:mb-6 mb-4 lg:mb-8 max-lg:pr-5 max-md:px-4">
|
|
||||||
<div>
|
<div>
|
||||||
<h2 class="display-7 text-primary-600">Cấp giấy chứng nhận</h2>
|
<h2 class="display-7 text-primary-600">Cấp giấy chứng nhận</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex md:gap-2 gap-1 items-center">
|
||||||
<div class="flex md:gap-2 gap-1">
|
<button class="btn-navigation-cap-giay-chung-nhan-01-prev md:size-10 size-8 flex items-center justify-center rounded-full bg-white shadow-sm border border-gray-100 hover:bg-primary-600 hover:text-white transition-colors cursor-pointer group">
|
||||||
<button class="btn-navigation-cap-giay-chung-nhan-prev md:size-10 size-8 flex items-center justify-center rounded-full bg-white shadow-sm border border-gray-100 hover:bg-primary-600 hover:text-white transition-colors cursor-pointer group">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevron-up size-4 -rotate-90">
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevron-up size-4 -rotate-90">
|
||||||
<path d="m18 15-6-6-6 6"></path>
|
<path d="m18 15-6-6-6 6"></path>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<button class="btn-navigation-cap-giay-chung-nhan-next md:size-10 size-8 flex items-center justify-center rounded-full bg-white shadow-sm border border-gray-100 hover:bg-primary-600 hover:text-white transition-colors cursor-pointer group">
|
<div class="swiper swiper-giay-chung-nhan-01 w-full">
|
||||||
|
<div class="swiper-wrapper">
|
||||||
|
<th:block th:utext="${hookManager.applyFilters('swiper_slider_items', '', 'giay-chung-nhan-01')}"></th:block>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="btn-navigation-cap-giay-chung-nhan-01-next md:size-10 size-8 flex items-center justify-center rounded-full bg-white shadow-sm border border-gray-100 hover:bg-primary-600 hover:text-white transition-colors cursor-pointer group">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevron-down size-4 -rotate-90">
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevron-down size-4 -rotate-90">
|
||||||
<path d="m6 9 6 6 6-6"></path>
|
<path d="m6 9 6 6 6-6"></path>
|
||||||
</svg>
|
</svg>
|
||||||
@@ -205,50 +175,39 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="relative max-md:px-4 md:mb-6 mb-4 lg:mb-8">
|
<div class="lg:container max-lg:pl-5 max-md:px-0 bg-[#f6f6f6]">
|
||||||
<div class="swiper swiper-giay-chung-nhan w-full">
|
<div class="xl:py-12 md:py-8 py-6 xl:space-y-12 md:space-y-8 space-y-6">
|
||||||
<div class="swiper-wrapper">
|
|
||||||
<th:block th:utext="${hookManager.applyFilters('swiper_slider_items', '', 'giay-chung-nhan')}"></th:block>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="lg:container max-lg:pl-5 max-md:px-0">
|
|
||||||
<div class="flex justify-between items-end md:mb-6 mb-4 lg:mb-8 max-lg:pr-5 max-md:px-4">
|
|
||||||
<div>
|
<div>
|
||||||
<h2 class="display-7 text-primary-600">Đào tạo cấp giấy xác nhận quá trình thực hành</h2>
|
<h2 class="display-7 text-primary-600">Đào tạo cấp giấy xác nhận quá trình thực hành</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex md:gap-2 gap-1 items-center">
|
||||||
<div class="flex md:gap-2 gap-1">
|
<button class="btn-xac-nhan-thuc-hanh-prev md:size-10 size-8 flex items-center justify-center rounded-full bg-white shadow-sm border border-gray-100 hover:bg-primary-600 hover:text-white transition-colors cursor-pointer group">
|
||||||
<button class="btn-navigation-dao-tao-cap-giay-xac-nhan-qua-trinh-thuc-hanh-prev md:size-10 size-8 flex items-center justify-center rounded-full bg-white shadow-sm border border-gray-100 hover:bg-primary-600 hover:text-white transition-colors cursor-pointer group">
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevron-up size-4 -rotate-90">
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevron-up size-4 -rotate-90">
|
||||||
<path d="m18 15-6-6-6 6"></path>
|
<path d="m18 15-6-6-6 6"></path>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<button class="btn-navigation-dao-tao-cap-giay-xac-nhan-qua-trinh-thuc-hanh-next md:size-10 size-8 flex items-center justify-center rounded-full bg-white shadow-sm border border-gray-100 hover:bg-primary-600 hover:text-white transition-colors cursor-pointer group">
|
<div class="swiper w-full swiper-xac-nhan-thuc-hanh">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevron-down size-4 -rotate-90">
|
|
||||||
<path d="m6 9 6 6 6-6"></path>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="relative max-md:px-4 md:mb-6 mb-4 lg:mb-8">
|
|
||||||
<div class="swiper swiper-xac-nhan-thuc-hanh w-full">
|
|
||||||
<div class="swiper-wrapper">
|
<div class="swiper-wrapper">
|
||||||
<th:block th:utext="${hookManager.applyFilters('swiper_slider_items', '', 'xac-nhan-thuc-hanh')}"></th:block>
|
<th:block th:utext="${hookManager.applyFilters('swiper_slider_items', '', 'xac-nhan-thuc-hanh')}"></th:block>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<button class="btn-xac-nhan-thuc-hanh-next md:size-10 size-8 flex items-center justify-center rounded-full bg-white shadow-sm border border-gray-100 hover:bg-primary-600 hover:text-white transition-colors cursor-pointer group">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevron-down size-4 -rotate-90">
|
||||||
|
<path d="m6 9 6 6 6-6"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section class="xl:py-8 md:py-6 py-4">
|
|
||||||
|
<section class="xl:py-8 md:py-6 py-4 bg-white">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="grid grid-cols-12 xl:gap-x-8 md:gap-x-6 max-md:gap-y-4">
|
<div class="grid grid-cols-12 xl:gap-x-8 md:gap-x-6 max-md:gap-y-4">
|
||||||
<div class="xl:col-span-4 lg:col-span-5 col-span-full space-y-2">
|
<div class="xl:col-span-4 lg:col-span-5 col-span-full space-y-2">
|
||||||
<div class="display-7 text-primary-600 md:mb-3 mb-2">Đào tạo thực hành sinh viên, học viên Đại học Y Dược TP. Hồ Chí Minh</div>
|
<div class="display-7 text-primary-600 md:mb-3 mb-2">Đào tạo thực hành sinh viên, học viên Bệnh viện Đa Khoa Quốc Tế S.I.S Cần Thơ</div>
|
||||||
<div class="md:mb-6 mb-4 prose prose-content">
|
<div class="md:mb-6 mb-4 prose prose-content">
|
||||||
<p>Với chức năng là cơ sở giảng dạy thực hành, đào tạo là một trong những hoạt động nòng cốt của Bệnh viện Đại học Y Dược TP. Hồ Chí Minh, nhằm tối ưu công tác đào tạo, Bệnh viện đa dạng hóa các loại hình đào tạo để đáp ứng các yêu cầu về đào tạo của nội bộ, của các cơ sở y tế khác và của học viên nước ngoài.</p>
|
<p>Với chức năng là cơ sở giảng dạy thực hành, đào tạo là một trong những hoạt động nòng cốt của Bệnh viện Bệnh viện Đa Khoa Quốc Tế S.I.S Cần Thơ, nhằm tối ưu công tác đào tạo, Bệnh viện đa dạng hóa các loại hình đào tạo để đáp ứng các yêu cầu về đào tạo của nội bộ, của các cơ sở y tế khác và của học viên nước ngoài.</p>
|
||||||
</div>
|
</div>
|
||||||
<a class="btn btn-light label-2" href="https://bvdaihoc.com.vn/dao-tao-thuc-hanh-sv-hv-dhyd-tphcm">Xem chi tiết</a>
|
<a class="btn btn-light label-2" href="https://bvdaihoc.com.vn/dao-tao-thuc-hanh-sv-hv-dhyd-tphcm">Xem chi tiết</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -260,7 +219,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section class="xl:py-8 md:py-6 py-4 bg-gray-50">
|
<section class="xl:py-8 md:py-6 py-4 bg-[#f6f6f6]">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="display-7 text-primary-600 md:mb-3 mb-2">Đào tạo - Chuyển giao kỹ thuật</div>
|
<div class="display-7 text-primary-600 md:mb-3 mb-2">Đào tạo - Chuyển giao kỹ thuật</div>
|
||||||
<div class="md:mb-6 mb-4 prose prose-content">
|
<div class="md:mb-6 mb-4 prose prose-content">
|
||||||
@@ -272,7 +231,7 @@
|
|||||||
<a class="btn btn-light label-2" href="https://bvdaihoc.com.vn/dao-tao/dao-tao-chuyen-giao-ky-thuat">Xem chi tiết</a>
|
<a class="btn btn-light label-2" href="https://bvdaihoc.com.vn/dao-tao/dao-tao-chuyen-giao-ky-thuat">Xem chi tiết</a>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section class="xl:py-8 md:py-6 py-4">
|
<section class="xl:py-8 md:py-6 py-4 bg-white">
|
||||||
<div class="container xl:space-y-8 md:space-y-6 space-y-4">
|
<div class="container xl:space-y-8 md:space-y-6 space-y-4">
|
||||||
<div class="relative aspect-[2/1] rounded-lg overflow-hidden">
|
<div class="relative aspect-[2/1] rounded-lg overflow-hidden">
|
||||||
<img alt="training" loading="lazy" decoding="async" data-nimg="fill" style="position:absolute;height:100%;width:100%;left:0;top:0;right:0;bottom:0;object-fit:cover;color:transparent" th:src="@{/images/dao-tao/training-2.jpeg}">
|
<img alt="training" loading="lazy" decoding="async" data-nimg="fill" style="position:absolute;height:100%;width:100%;left:0;top:0;right:0;bottom:0;object-fit:cover;color:transparent" th:src="@{/images/dao-tao/training-2.jpeg}">
|
||||||
@@ -286,26 +245,22 @@
|
|||||||
<a class="btn btn-light label-2" href="https://bvdaihoc.com.vn/dao-tao-thuc-hanh-lam-sang">Xem chi tiết</a>
|
<a class="btn btn-light label-2" href="https://bvdaihoc.com.vn/dao-tao-thuc-hanh-lam-sang">Xem chi tiết</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="xl:col-span-8 lg:col-span-7 col-span-full">
|
<div class="xl:col-span-8 lg:col-span-7 col-span-full">
|
||||||
<div class="relative">
|
<div class="flex flex-row h-full items-center">
|
||||||
<button class="btn-navigation absolute left-2 top-1/2 -translate-y-1/2 z-10 md:size-[42px] size-[32px] flex-shrink-0 items-center justify-center rounded-full btn-navigation-practical-prev md:flex hidden">
|
<button class="btn-dao-tao-lam-sang-01-prev btn-navigation md:size-[42px] size-[32px] flex-shrink-0 items-center justify-center rounded-full md:flex">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevron-up size-4 -rotate-90">
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevron-up size-4 -rotate-90">
|
||||||
<path d="m18 15-6-6-6 6"></path>
|
<path d="m18 15-6-6-6 6"></path>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<div class="swiper swiper-hinh-anh-thuc-hanh w-full">
|
<div class="swiper swiper-dao-tao-lam-sang-01 w-full h-full">
|
||||||
<div class="swiper-wrapper">
|
<div class="swiper-wrapper">
|
||||||
<th:block th:utext="${hookManager.applyFilters('swiper_slider_items', '', 'hinh-anh-thuc-hanh')}"></th:block>
|
<th:block th:utext="${hookManager.applyFilters('swiper_slider_items', '', 'dao-tao-lam-sang-01')}"></th:block>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn-navigation absolute right-2 top-1/2 -translate-y-1/2 z-10 md:size-[42px] size-[32px] flex-shrink-0 items-center justify-center rounded-full btn-navigation-practical-next md:flex hidden">
|
</div>
|
||||||
|
<button class="btn-dao-tao-lam-sang-01-next btn-navigation md:size-[42px] size-[32px] flex-shrink-0 items-center justify-center rounded-full md:flex">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevron-down size-4 -rotate-90">
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevron-down size-4 -rotate-90">
|
||||||
<path d="m6 9 6 6 6-6"></path>
|
<path d="m6 9 6 6 6-6"></path>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<div class="swiper-pagination swiper-pagination-training-practical space-x-[13px] !relative [&_.swiper-pagination-bullet-active]:!bg-primary-600 [&_.swiper-pagination-bullet-active]:!opacity-100 [&_.swiper-pagination-bullet-active]:before:opacity-100 mt-6 flex justify-center md:hidden [&_.swiper-pagination-bullet]:!relative [&_.swiper-pagination-bullet]:before:content-[''] [&_.swiper-pagination-bullet]:before:size-[14px] [&_.swiper-pagination-bullet]:before:bg-transparent [&_.swiper-pagination-bullet]:before:rounded-full [&_.swiper-pagination-bullet]:before:border [&_.swiper-pagination-bullet]:before:border-primary-600 [&_.swiper-pagination-bullet]:before:top-[-2.7px] [&_.swiper-pagination-bullet]:before:left-[-2.7px] [&_.swiper-pagination-bullet]:before:absolute [&_.swiper-pagination-bullet]:before:block [&_.swiper-pagination-bullet]:before:opacity-0 [&_.swiper-pagination-bullet]:!size-[8px] [&_.swiper-pagination-bullet]:!bg-gray-600 swiper-pagination-clickable swiper-pagination-bullets swiper-pagination-horizontal">
|
|
||||||
<span class="swiper-pagination-bullet swiper-pagination-bullet-active"></span>
|
|
||||||
<span class="swiper-pagination-bullet"></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -323,85 +278,7 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<!-- Swiper JS & Initialization -->
|
|
||||||
<script th:src="@{/js/swiper-bundle.min.js}"></script>
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/swiper@11/swiper-bundle.min.js"></script>
|
|
||||||
<script>
|
|
||||||
function initDaoTaoSliders() {
|
|
||||||
if (typeof Swiper === 'undefined') {
|
|
||||||
console.error("Swiper JS is not loaded!");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const sliderConfigs = [{
|
|
||||||
slug: 'dao-tao-01',
|
|
||||||
prev: '.btn-navigation-dao-tao-01-prev',
|
|
||||||
next: '.btn-navigation-dao-tao-01-next',
|
|
||||||
pagination: ''
|
|
||||||
}, {
|
|
||||||
slug: 'khoa-hoc',
|
|
||||||
prev: '.btn-navigation-khoa-hoc-prev',
|
|
||||||
next: '.btn-navigation-khoa-hoc-next',
|
|
||||||
pagination: ''
|
|
||||||
}, {
|
|
||||||
slug: 'chung-chi',
|
|
||||||
prev: '.btn-navigation-cap-chung-chi-prev',
|
|
||||||
next: '.btn-navigation-cap-chung-chi-next',
|
|
||||||
pagination: ''
|
|
||||||
}, {
|
|
||||||
slug: 'giay-chung-nhan',
|
|
||||||
prev: '.btn-navigation-cap-giay-chung-nhan-prev',
|
|
||||||
next: '.btn-navigation-cap-giay-chung-nhan-next',
|
|
||||||
pagination: ''
|
|
||||||
}, {
|
|
||||||
slug: 'xac-nhan-thuc-hanh',
|
|
||||||
prev: '.btn-navigation-dao-tao-cap-giay-xac-nhan-qua-trinh-thuc-hanh-prev',
|
|
||||||
next: '.btn-navigation-dao-tao-cap-giay-xac-nhan-qua-trinh-thuc-hanh-next',
|
|
||||||
pagination: ''
|
|
||||||
}, {
|
|
||||||
slug: 'hinh-anh-thuc-hanh',
|
|
||||||
prev: '.btn-navigation-practical-prev',
|
|
||||||
next: '.btn-navigation-practical-next',
|
|
||||||
pagination: '.swiper-pagination-training-practical'
|
|
||||||
}];
|
|
||||||
sliderConfigs.forEach(function(config) {
|
|
||||||
const el = document.querySelector('.swiper-' + config.slug);
|
|
||||||
if (el) {
|
|
||||||
if (el.swiper) {
|
|
||||||
el.swiper.destroy(true, true);
|
|
||||||
}
|
|
||||||
new Swiper('.swiper-' + config.slug, {
|
|
||||||
slidesPerView: 'auto',
|
|
||||||
spaceBetween: 16,
|
|
||||||
observer: true,
|
|
||||||
observeParents: true,
|
|
||||||
watchOverflow: false,
|
|
||||||
rewind: true,
|
|
||||||
navigation: {
|
|
||||||
nextEl: config.next,
|
|
||||||
prevEl: config.prev,
|
|
||||||
},
|
|
||||||
pagination: config.pagination ? {
|
|
||||||
el: config.pagination,
|
|
||||||
clickable: true,
|
|
||||||
} : false
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (document.readyState === 'loading') {
|
|
||||||
document.addEventListener('DOMContentLoaded', initDaoTaoSliders);
|
|
||||||
} else {
|
|
||||||
initDaoTaoSliders();
|
|
||||||
}
|
|
||||||
window.addEventListener('load', initDaoTaoSliders);
|
|
||||||
</script>
|
|
||||||
</div>
|
|
||||||
<th:block layout:fragment="scripts">
|
<th:block layout:fragment="scripts">
|
||||||
<script>
|
|
||||||
if (typeof initDaoTaoSliders === 'function') {
|
|
||||||
initDaoTaoSliders();
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
</th:block>
|
</th:block>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -1,29 +1,32 @@
|
|||||||
<!DOCTYPE html>
|
<!doctype html>
|
||||||
<html lang="en" xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
|
<html lang="en" xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
|
||||||
|
<head>
|
||||||
<head>
|
<meta charset="UTF-8" />
|
||||||
<meta charset="UTF-8">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>UMass Amherst</title>
|
<title>UMass Amherst</title>
|
||||||
|
|
||||||
<!-- Google Fonts: Lora and Open Sans -->
|
<!-- Google Fonts: Lora and Open Sans -->
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
<link
|
<link
|
||||||
href="https://fonts.googleapis.com/css2?family=Lora:ital,wght@0,400..700;1,400..700&family=Open+Sans:ital,wght@0,300..800;1,300..800&display=swap"
|
href="https://fonts.googleapis.com/css2?family=Lora:ital,wght@0,400..700;1,400..700&family=Open+Sans:ital,wght@0,300..800;1,300..800&display=swap"
|
||||||
rel="stylesheet">
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- Add base CSS here if available -->
|
<!-- Add base CSS here if available -->
|
||||||
<th:block layout:fragment="head"></th:block>
|
<th:block layout:fragment="head"></th:block>
|
||||||
|
|
||||||
<!-- UMASS Styles -->
|
<!-- UMASS Styles -->
|
||||||
<link rel="stylesheet" th:href="@{/css/umass.css}">
|
<link rel="stylesheet" th:href="@{/css/umass.css}" />
|
||||||
|
|
||||||
|
<!-- 🟢 1. SWIPER.JS CSS (Đã thêm) -->
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/swiper@11/swiper-bundle.min.css" />
|
||||||
|
|
||||||
<!-- Custom CSS (Highest Priority) -->
|
<!-- Custom CSS (Highest Priority) -->
|
||||||
<link rel="stylesheet" th:href="@{/css/custom.css}">
|
<link rel="stylesheet" th:href="@{/css/custom.css}" />
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body th:class="${bodyClass}">
|
<body th:class="${bodyClass}">
|
||||||
<!-- Skip to main content link (Accessibility) -->
|
<!-- Skip to main content link (Accessibility) -->
|
||||||
<a href="#main-content" class="visually-hidden focusable skip-link">Skip to main content</a>
|
<a href="#main-content" class="visually-hidden focusable skip-link">Skip to main content</a>
|
||||||
|
|
||||||
@@ -42,11 +45,14 @@
|
|||||||
<div th:replace="~{themes/__${activeTheme}__/footer :: footer}"></div>
|
<div th:replace="~{themes/__${activeTheme}__/footer :: footer}"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 🟢 2. SWIPER.JS SCRIPT (Đã thêm) -->
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/swiper@11/swiper-bundle.min.js"></script>
|
||||||
|
|
||||||
<!-- Core Scripts -->
|
<!-- Core Scripts -->
|
||||||
<script th:src="@{/js/mobile-menu.js}"></script>
|
<script th:src="@{/js/mobile-menu.js}"></script>
|
||||||
|
<script th:src="@{/js/main.js}"></script>
|
||||||
|
|
||||||
<!-- Page Specific Scripts -->
|
<!-- Page Specific Scripts -->
|
||||||
<th:block layout:fragment="scripts"></th:block>
|
<th:block layout:fragment="scripts"></th:block>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
@@ -1,20 +1,48 @@
|
|||||||
<html lang="en" xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{fragments/manage-layout}">
|
<html
|
||||||
<head>
|
lang="en"
|
||||||
|
xmlns:th="http://www.thymeleaf.org"
|
||||||
|
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
|
||||||
|
layout:decorate="~{fragments/manage-layout}"
|
||||||
|
>
|
||||||
|
<head>
|
||||||
<title th:text="${pageTitle} + ' - SIS Vietnam'">Swiper Slider Settings</title>
|
<title th:text="${pageTitle} + ' - SIS Vietnam'">Swiper Slider Settings</title>
|
||||||
<style>
|
<style>
|
||||||
.item-card { border: 1px solid #ddd; margin-bottom: 1rem; padding: 1rem; border-radius: 5px; background: #fff;}
|
.item-card {
|
||||||
.tab-field { margin-bottom: 0.5rem; }
|
border: 1px solid #ddd;
|
||||||
.tab-field label { display: block; font-weight: bold; margin-bottom: 0.2rem;}
|
margin-bottom: 1rem;
|
||||||
.tab-field input, .tab-field textarea, .tab-field select { width: 100%; padding: 0.5rem; border: 1px solid #ccc; border-radius: 3px;}
|
padding: 1rem;
|
||||||
.tab-field textarea { height: 80px; }
|
border-radius: 5px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.tab-field {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
.tab-field label {
|
||||||
|
display: block;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 0.2rem;
|
||||||
|
}
|
||||||
|
.tab-field input,
|
||||||
|
.tab-field textarea,
|
||||||
|
.tab-field select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
.tab-field textarea {
|
||||||
|
height: 80px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div layout:fragment="content">
|
<div layout:fragment="content">
|
||||||
<div class="container-fluid">
|
<div class="container-fluid">
|
||||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
<h1 class="h3 mb-0 text-gray-800" th:text="${pageTitle}">Sửa Nhóm Slider</h1>
|
<h1 class="h3 mb-0 text-gray-800" th:text="${pageTitle}">Sửa Nhóm Slider</h1>
|
||||||
<a th:href="@{/manage/plugins/swiper-slider}" class="btn btn-secondary shadow-sm"><i class="fas fa-arrow-left fa-sm text-white-50"></i> Quay lại Danh sách</a>
|
<a th:href="@{/manage/plugins/swiper-slider}" class="btn btn-secondary shadow-sm"
|
||||||
|
><i class="fas fa-arrow-left fa-sm text-white-50"></i> Quay lại Danh sách</a
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div th:if="${errorMessage}" class="alert alert-danger" th:text="${errorMessage}"></div>
|
<div th:if="${errorMessage}" class="alert alert-danger" th:text="${errorMessage}"></div>
|
||||||
@@ -31,35 +59,150 @@
|
|||||||
<div class="row mb-3">
|
<div class="row mb-3">
|
||||||
<div class="col-md-6 tab-field">
|
<div class="col-md-6 tab-field">
|
||||||
<label>Tên Nhóm (Chỉ để quản lý)</label>
|
<label>Tên Nhóm (Chỉ để quản lý)</label>
|
||||||
<input type="text" class="form-control" onchange="updateGroup('name', this.value)" id="groupName" placeholder="Ví dụ: Đào tạo - Chứng chỉ" required />
|
<input
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
onchange="updateGroup('name', this.value)"
|
||||||
|
id="groupName"
|
||||||
|
placeholder="Ví dụ: Đào tạo - Chứng chỉ"
|
||||||
|
required
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6 tab-field">
|
<div class="col-md-6 tab-field">
|
||||||
<label>Slug (Dùng cho Shortcode)</label>
|
<label>Slug (Dùng cho Shortcode)</label>
|
||||||
<input type="text" class="form-control" onchange="updateGroup('slug', this.value)" id="groupSlug" placeholder="Ví dụ: chung-chi" required />
|
<input
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
onchange="updateGroup('slug', this.value)"
|
||||||
|
id="groupSlug"
|
||||||
|
placeholder="Ví dụ: chung-chi"
|
||||||
|
required
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6 tab-field mt-3">
|
<div class="col-md-6 tab-field mt-3">
|
||||||
<label>Ghi chú (Hiển thị ở danh sách)</label>
|
<label>Ghi chú (Hiển thị ở danh sách)</label>
|
||||||
<input type="text" class="form-control" onchange="updateGroup('note', this.value)" id="groupNote" placeholder="Ví dụ: Dùng cho trang chủ" />
|
<input
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
onchange="updateGroup('note', this.value)"
|
||||||
|
id="groupNote"
|
||||||
|
placeholder="Ví dụ: Dùng cho trang chủ"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6 tab-field mt-3">
|
<div class="col-md-6 tab-field mt-3">
|
||||||
<label>Mẫu Giao Diện (Layout Type)</label>
|
<label>Mẫu Giao Diện (Layout Type)</label>
|
||||||
<select class="form-control" onchange="updateGroup('layoutType', this.value); toggleCustomTemplates(this.value)" id="groupLayoutType">
|
<select
|
||||||
|
class="form-control"
|
||||||
|
onchange="
|
||||||
|
updateGroup('layoutType', this.value);
|
||||||
|
toggleCustomTemplates(this.value);
|
||||||
|
"
|
||||||
|
id="groupLayoutType"
|
||||||
|
>
|
||||||
<option value="">Mặc định (Dựa theo Slug, hỗ trợ tương thích ngược)</option>
|
<option value="">Mặc định (Dựa theo Slug, hỗ trợ tương thích ngược)</option>
|
||||||
<option value="gallery">Mẫu 1: Thư viện ảnh (Gallery - ảnh chữ nhật nhỏ)</option>
|
<option value="gallery">Mẫu 1: Thư viện ảnh (Gallery - ảnh chữ nhật nhỏ)</option>
|
||||||
<option value="card">Mẫu 2: Dạng thẻ (Khóa học/Chứng chỉ - ảnh vuông + tiêu đề)</option>
|
<option value="card">Mẫu 2: Dạng thẻ (Khóa học/Chứng chỉ - ảnh vuông + tiêu đề)</option>
|
||||||
<option value="large-image">Mẫu 3: Hình ảnh lớn (Thực hành - ảnh chữ nhật lớn)</option>
|
<option value="large-image">Mẫu 3: Hình ảnh lớn (Thực hành - ảnh chữ nhật lớn)</option>
|
||||||
<option value="custom">Tùy chỉnh (Nhập mã HTML bên dưới)</option>
|
<option value="custom">Tùy chỉnh (Nhập mã HTML bên dưới)</option>
|
||||||
|
<optgroup label="Mẫu thiết kế Database (ComponentTemplate)">
|
||||||
|
<option th:each="tpl : ${componentTemplates}"
|
||||||
|
th:value="${tpl.slug}"
|
||||||
|
th:text="${tpl.name + ' (' + tpl.slug + ')'}">
|
||||||
|
</option>
|
||||||
|
</optgroup>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-12 tab-field mt-3 custom-template-container" style="display: none;">
|
<div class="col-md-6 tab-field mt-3">
|
||||||
<label>Outer Template (Cấu trúc bao ngoài Slider, tùy chọn)</label>
|
<label>Nguồn Dữ Liệu</label>
|
||||||
<textarea class="form-control" style="font-family: monospace; height: 120px;" onchange="updateGroup('outerTemplate', this.value)" id="groupOuterTemplate" placeholder="Ví dụ: <div class='swiper swiper-{{slug}}'><div class='swiper-wrapper'>{{#each items}}{{/each}}</div></div>"></textarea>
|
<select
|
||||||
<small class="text-muted">Dùng <code>{{slug}}</code> cho slug của nhóm và <code>{{#each items}}{{/each}}</code> để đánh dấu nơi đặt nội dung slides.</small>
|
class="form-control"
|
||||||
|
onchange="
|
||||||
|
updateGroup('dataSource', this.value);
|
||||||
|
toggleDataSource(this.value);
|
||||||
|
"
|
||||||
|
id="groupDataSource"
|
||||||
|
>
|
||||||
|
<option value="MANUAL">Nhập thủ công</option>
|
||||||
|
<option value="POST">Bài viết (Posts)</option>
|
||||||
|
<option value="PAGE">Trang (Pages)</option>
|
||||||
|
<option value="MEDIA">Thư viện ảnh (Media Library)</option>
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-12 tab-field mt-2 custom-template-container" style="display: none;">
|
<div class="col-md-6 tab-field mt-3 dynamic-source-container source-filter-container" style="display: none">
|
||||||
|
<div>
|
||||||
|
<label>Chuyên mục (Categories) (Tùy chọn)</label>
|
||||||
|
<select class="form-control" onchange="updateMultiGroup('categoryIds', this)" id="groupCategoryIds">
|
||||||
|
<option value="">-- Chọn chuyên mục --</option>
|
||||||
|
<option th:each="cat : ${allCategories}" th:value="${cat.id}" th:text="${cat.name}"></option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div id="selected_categoryIds"></div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 tab-field mt-3 dynamic-source-container source-filter-container" style="display: none">
|
||||||
|
<div>
|
||||||
|
<label>Thẻ (Tags) (Tùy chọn)</label>
|
||||||
|
<select class="form-control" onchange="updateMultiGroup('tagIds', this)" id="groupTagIds">
|
||||||
|
<option value="">-- Chọn thẻ --</option>
|
||||||
|
<option th:each="tag : ${allTags}" th:value="${tag.id}" th:text="${tag.name}"></option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div id="selected_tagIds"></div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 tab-field mt-3 dynamic-source-container" style="display: none">
|
||||||
|
<label>Số lượng hiển thị (Tùy chọn)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
class="form-control"
|
||||||
|
onchange="updateGroup('itemLimit', this.value ? parseInt(this.value, 10) : null)"
|
||||||
|
id="groupItemLimit"
|
||||||
|
placeholder="Ví dụ: 10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 tab-field mt-3 media-source-container" style="display: none">
|
||||||
|
<label>Chọn Hình Ảnh (Thư viện ảnh)</label>
|
||||||
|
<div>
|
||||||
|
<button type="button" class="btn btn-info btn-sm mb-2" onclick="openMediaModal()">Chọn từ Thư viện</button>
|
||||||
|
</div>
|
||||||
|
<div id="selected_mediaIds"></div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 tab-field mt-3 dynamic-source-container source-filter-container" style="display: none">
|
||||||
|
<div class="form-check mt-4">
|
||||||
|
<input
|
||||||
|
class="form-check-input"
|
||||||
|
type="checkbox"
|
||||||
|
id="groupUseExcerptAsDescription"
|
||||||
|
onchange="updateGroup('useExcerptAsDescription', this.checked)"
|
||||||
|
/>
|
||||||
|
<label class="form-check-label" for="groupUseExcerptAsDescription"> Dùng Excerpt làm Description </label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-12 tab-field mt-3 custom-template-container" style="display: none">
|
||||||
|
<label>Outer Template (Cấu trúc bao ngoài Slider, tùy chọn)</label>
|
||||||
|
<textarea
|
||||||
|
class="form-control"
|
||||||
|
style="font-family: monospace; height: 120px"
|
||||||
|
onchange="updateGroup('outerTemplate', this.value)"
|
||||||
|
id="groupOuterTemplate"
|
||||||
|
placeholder="Ví dụ: <div class='swiper swiper-{{slug}}'><div class='swiper-wrapper'>{{#each items}}{{/each}}</div></div>"
|
||||||
|
></textarea>
|
||||||
|
<small class="text-muted"
|
||||||
|
>Dùng <code>{{slug}}</code> cho slug của nhóm và <code>{{#each items}}{{/each}}</code> để đánh dấu nơi đặt nội dung
|
||||||
|
slides.</small
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-12 tab-field mt-2 custom-template-container" style="display: none">
|
||||||
<label>Item Template (Cấu trúc 1 Slide con, tùy chọn)</label>
|
<label>Item Template (Cấu trúc 1 Slide con, tùy chọn)</label>
|
||||||
<textarea class="form-control" style="font-family: monospace; height: 100px;" onchange="updateGroup('itemTemplate', this.value)" id="groupItemTemplate" placeholder="Ví dụ: <div class='swiper-slide'><img src='{{imageUrl}}' alt='{{title}}'></div>"></textarea>
|
<textarea
|
||||||
<small class="text-muted">Các biến hỗ trợ: <code>{{title}}</code>, <code>{{imageUrl}}</code>, <code>{{linkUrl}}</code>, <code>{{description}}</code>.</small>
|
class="form-control"
|
||||||
|
style="font-family: monospace; height: 100px"
|
||||||
|
onchange="updateGroup('itemTemplate', this.value)"
|
||||||
|
id="groupItemTemplate"
|
||||||
|
placeholder="Ví dụ: <div class='swiper-slide'><img src='{{imageUrl}}' alt='{{title}}'></div>"
|
||||||
|
></textarea>
|
||||||
|
<small class="text-muted"
|
||||||
|
>Các biến hỗ trợ: <code>{{title}}</code>, <code>{{imageUrl}}</code>, <code>{{linkUrl}}</code>,
|
||||||
|
<code>{{description}}</code>.</small
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -75,16 +218,61 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="submit" class="btn btn-primary btn-lg mb-4" onclick="return saveGroup()">Lưu Nhóm Slider</button>
|
<button type="submit" class="btn btn-primary btn-lg mb-4" onclick="return saveGroup();">Lưu Nhóm Slider</button>
|
||||||
</form>
|
</form>
|
||||||
|
<div class="modal fade" id="mediaLibraryModal" tabindex="-1" role="dialog" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-xl" role="document">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">Thư viện ảnh</h5>
|
||||||
|
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
|
||||||
|
<span aria-hidden="true">×</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body" style="max-height: 70vh; overflow-y: auto;">
|
||||||
|
<div id="mediaLibraryGrid" class="row">
|
||||||
|
<div class="col-12 text-center py-4"><div class="spinner-border text-primary" role="status"></div> Đang tải...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-dismiss="modal">Đóng</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script th:inline="javascript">
|
<script th:inline="javascript">
|
||||||
let group = /*[[${groupJson}]]*/ '{}';
|
let group = /*[[${groupJson}]]*/ '{}';
|
||||||
if (typeof group === 'string') {
|
if (typeof group === 'string') {
|
||||||
try { group = JSON.parse(group); } catch(e) { group = {slug: '', name: '', layoutType: '', outerTemplate: '', itemTemplate: '', items: []}; }
|
try {
|
||||||
|
group = JSON.parse(group);
|
||||||
|
} catch (e) {
|
||||||
|
group = { slug: '', name: '', layoutType: '', outerTemplate: '', itemTemplate: '', items: [] };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (!group.items) group.items = [];
|
if (!group.items) group.items = [];
|
||||||
|
window.group = group;
|
||||||
|
|
||||||
|
function renderBadge(container, field, val, text) {
|
||||||
|
const badge = document.createElement('span');
|
||||||
|
badge.className = 'category-badge';
|
||||||
|
badge.setAttribute('data-value', val);
|
||||||
|
badge.setAttribute('data-field', field);
|
||||||
|
|
||||||
|
const badgeText = document.createElement('span');
|
||||||
|
badgeText.className = 'badge-text';
|
||||||
|
badgeText.innerText = text;
|
||||||
|
|
||||||
|
const removeBtn = document.createElement('button');
|
||||||
|
removeBtn.type = 'button';
|
||||||
|
removeBtn.className = 'remove_category-badge';
|
||||||
|
removeBtn.innerHTML = '×';
|
||||||
|
|
||||||
|
badge.appendChild(badgeText);
|
||||||
|
badge.appendChild(removeBtn);
|
||||||
|
container.appendChild(badge);
|
||||||
|
}
|
||||||
|
|
||||||
function renderGroup() {
|
function renderGroup() {
|
||||||
document.getElementById('groupName').value = group.name || '';
|
document.getElementById('groupName').value = group.name || '';
|
||||||
@@ -93,7 +281,48 @@
|
|||||||
document.getElementById('groupLayoutType').value = group.layoutType || '';
|
document.getElementById('groupLayoutType').value = group.layoutType || '';
|
||||||
document.getElementById('groupOuterTemplate').value = group.outerTemplate || '';
|
document.getElementById('groupOuterTemplate').value = group.outerTemplate || '';
|
||||||
document.getElementById('groupItemTemplate').value = group.itemTemplate || '';
|
document.getElementById('groupItemTemplate').value = group.itemTemplate || '';
|
||||||
|
|
||||||
toggleCustomTemplates(group.layoutType || '');
|
toggleCustomTemplates(group.layoutType || '');
|
||||||
|
toggleDataSource(group.dataSource || 'MANUAL');
|
||||||
|
|
||||||
|
document.getElementById('groupDataSource').value = group.dataSource || 'MANUAL';
|
||||||
|
|
||||||
|
const selectedCategoryIdsContainer = document.getElementById('selected_categoryIds');
|
||||||
|
if (selectedCategoryIdsContainer) {
|
||||||
|
selectedCategoryIdsContainer.innerHTML = '';
|
||||||
|
if (group.categoryIds) {
|
||||||
|
group.categoryIds.forEach(id => {
|
||||||
|
const opt = document.querySelector(`#groupCategoryIds option[value="${id}"]`);
|
||||||
|
const text = opt ? opt.innerText : id;
|
||||||
|
renderBadge(selectedCategoryIdsContainer, 'categoryIds', id, text);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedTagIdsContainer = document.getElementById('selected_tagIds');
|
||||||
|
if (selectedTagIdsContainer) {
|
||||||
|
selectedTagIdsContainer.innerHTML = '';
|
||||||
|
if (group.tagIds) {
|
||||||
|
group.tagIds.forEach(id => {
|
||||||
|
const opt = document.querySelector(`#groupTagIds option[value="${id}"]`);
|
||||||
|
const text = opt ? opt.innerText : id;
|
||||||
|
renderBadge(selectedTagIdsContainer, 'tagIds', id, text);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedMediaIdsContainer = document.getElementById('selected_mediaIds');
|
||||||
|
if (selectedMediaIdsContainer) {
|
||||||
|
selectedMediaIdsContainer.innerHTML = '';
|
||||||
|
if (group.mediaIds) {
|
||||||
|
group.mediaIds.forEach(id => {
|
||||||
|
renderBadge(selectedMediaIdsContainer, 'mediaIds', id, 'Ảnh ID: ' + id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('groupItemLimit').value = group.itemLimit || '';
|
||||||
|
document.getElementById('groupUseExcerptAsDescription').checked = group.useExcerptAsDescription || false;
|
||||||
|
|
||||||
const container = document.getElementById('itemsContainer');
|
const container = document.getElementById('itemsContainer');
|
||||||
container.innerHTML = '';
|
container.innerHTML = '';
|
||||||
@@ -140,20 +369,121 @@
|
|||||||
|
|
||||||
function toggleCustomTemplates(layoutType) {
|
function toggleCustomTemplates(layoutType) {
|
||||||
const display = layoutType === 'custom' ? 'block' : 'none';
|
const display = layoutType === 'custom' ? 'block' : 'none';
|
||||||
document.querySelectorAll('.custom-template-container').forEach(el => el.style.display = display);
|
document.querySelectorAll('.custom-template-container').forEach(el => (el.style.display = display));
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleDataSource(dataSource) {
|
||||||
|
const isPostPage = dataSource === 'POST' || dataSource === 'PAGE';
|
||||||
|
const isDynamic = isPostPage || dataSource === 'MEDIA';
|
||||||
|
const isMedia = dataSource === 'MEDIA';
|
||||||
|
document.querySelectorAll('.dynamic-source-container').forEach(el => (el.style.display = isDynamic ? 'block' : 'none'));
|
||||||
|
document.querySelectorAll('.source-filter-container').forEach(el => (el.style.display = isPostPage ? 'block' : 'none'));
|
||||||
|
document.querySelectorAll('.media-source-container').forEach(el => (el.style.display = isMedia ? 'block' : 'none'));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mediaLoaded = false;
|
||||||
|
function openMediaModal() {
|
||||||
|
$('#mediaLibraryModal').modal('show');
|
||||||
|
if (!mediaLoaded) {
|
||||||
|
fetch('/api/manage/media/list')
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(data => {
|
||||||
|
const grid = document.getElementById('mediaLibraryGrid');
|
||||||
|
if (data.length === 0) {
|
||||||
|
grid.innerHTML = '<div class="col-12 text-center text-muted py-4">Thư viện trống.</div>';
|
||||||
|
} else {
|
||||||
|
grid.innerHTML = data.map(media => `
|
||||||
|
<div class="col-md-2 mb-3">
|
||||||
|
<div class="card h-100 cursor-pointer" style="cursor:pointer;" onclick="selectMediaItem(${media.id}, '${media.originalFilename}')">
|
||||||
|
<img src="${media.fileUrl}" class="card-img-top" style="height: 120px; object-fit: cover;" alt="${media.originalFilename}">
|
||||||
|
<div class="card-body p-2">
|
||||||
|
<p class="card-text small text-truncate m-0" title="${media.originalFilename}">${media.originalFilename}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
mediaLoaded = true;
|
||||||
|
}).catch(err => {
|
||||||
|
document.getElementById('mediaLibraryGrid').innerHTML = '<div class="col-12 text-center text-danger py-4">Lỗi tải ảnh!</div>';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.selectMediaItem = function(id, name) {
|
||||||
|
if (!group.mediaIds) group.mediaIds = [];
|
||||||
|
if (!group.mediaIds.includes(id)) {
|
||||||
|
group.mediaIds.push(id);
|
||||||
|
renderGroup();
|
||||||
|
}
|
||||||
|
$('#mediaLibraryModal').modal('hide');
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateGroup(field, value) {
|
function updateGroup(field, value) {
|
||||||
group[field] = value;
|
group[field] = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
window.updateMultiGroup = function (field, selectElement) {
|
||||||
|
const selectedValue = parseInt(selectElement.value, 10);
|
||||||
|
|
||||||
|
// Ignore default empty option selection
|
||||||
|
if (isNaN(selectedValue)) return;
|
||||||
|
|
||||||
|
// Initialize array state for this field if not present
|
||||||
|
if (!Array.isArray(window.group[field])) {
|
||||||
|
window.group[field] = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Add ID to array if not already selected (prevents duplicates)
|
||||||
|
if (!window.group[field].includes(selectedValue)) {
|
||||||
|
window.group[field].push(selectedValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Target the corresponding badge container
|
||||||
|
const container = document.getElementById('selected_' + field);
|
||||||
|
container.innerHTML = '';
|
||||||
|
|
||||||
|
// 3. Render badges from the window.group[field] array
|
||||||
|
window.group[field].forEach(val => {
|
||||||
|
const optionEl = selectElement.querySelector(`option[value="${val}"]`);
|
||||||
|
const text = optionEl ? optionEl.innerText : val;
|
||||||
|
renderBadge(container, field, val, text);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. Reset select dropdown back to placeholder option
|
||||||
|
selectElement.selectedIndex = 0;
|
||||||
|
|
||||||
|
console.log(`Current group[${field}]:`, window.group[field]);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Universal Remove Handler using Event Delegation in Vanilla JS
|
||||||
|
document.addEventListener('click', function (e) {
|
||||||
|
if (e.target && (e.target.matches('.remove_category-badge') || e.target.closest('.remove_category-badge'))) {
|
||||||
|
e.stopPropagation();
|
||||||
|
const badge = e.target.closest('.category-badge');
|
||||||
|
if (!badge) return;
|
||||||
|
|
||||||
|
const badgeVal = parseInt(badge.getAttribute('data-value'), 10);
|
||||||
|
const field = badge.getAttribute('data-field');
|
||||||
|
|
||||||
|
if (!isNaN(badgeVal) && field && Array.isArray(window.group[field])) {
|
||||||
|
// 1. Filter out deleted ID from the state array
|
||||||
|
window.group[field] = window.group[field].filter(val => val !== badgeVal);
|
||||||
|
console.log(`Updated group[${field}] after removal:`, window.group[field]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Remove badge UI element
|
||||||
|
badge.remove();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
function addItem() {
|
function addItem() {
|
||||||
group.items.push({title: '', imageUrl: '', linkUrl: '', description: ''});
|
group.items.push({ title: '', imageUrl: '', linkUrl: '', description: '' });
|
||||||
renderGroup();
|
renderGroup();
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeItem(iIndex) {
|
function removeItem(iIndex) {
|
||||||
if(confirm('Bạn có chắc muốn xóa slide này?')) {
|
if (confirm('Bạn có chắc muốn xóa slide này?')) {
|
||||||
group.items.splice(iIndex, 1);
|
group.items.splice(iIndex, 1);
|
||||||
renderGroup();
|
renderGroup();
|
||||||
}
|
}
|
||||||
@@ -176,7 +506,7 @@
|
|||||||
try {
|
try {
|
||||||
const response = await fetch('/api/manage/media/upload', {
|
const response = await fetch('/api/manage/media/upload', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: formData
|
body: formData,
|
||||||
});
|
});
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
if (result.success === 1 && result.file && result.file.url) {
|
if (result.success === 1 && result.file && result.file.url) {
|
||||||
@@ -204,10 +534,10 @@
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener("DOMContentLoaded", function() {
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
renderGroup();
|
renderGroup();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,22 +1,28 @@
|
|||||||
<!DOCTYPE html>
|
<!doctype html>
|
||||||
<html lang="en" xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
|
<html lang="en" xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8" />
|
||||||
<title>UMass Amherst Theme</title>
|
<title>UMass Amherst Theme</title>
|
||||||
<!-- UMass Amherst Assets -->
|
<!-- UMass Amherst Assets -->
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Open+Sans:wght@400;600;700&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Open+Sans:wght@400;600;700&display=swap" rel="stylesheet" />
|
||||||
<link rel="stylesheet" media="all" th:href="@{/theme-assets/umass/css/css_09S7WcUyhLeUXabknzECn_ua5zOangtv2BF4OXAW2fM.css}">
|
<link rel="stylesheet" media="all" th:href="@{/theme-assets/umass/css/css_09S7WcUyhLeUXabknzECn_ua5zOangtv2BF4OXAW2fM.css}" />
|
||||||
<link rel="stylesheet" media="all" th:href="@{/theme-assets/umass/css/css_5USufIKxGNJyomvgpy2m9ITQlTN9qcrtJQsw_06dSbo.css}">
|
<link rel="stylesheet" media="all" th:href="@{/theme-assets/umass/css/css_5USufIKxGNJyomvgpy2m9ITQlTN9qcrtJQsw_06dSbo.css}" />
|
||||||
<link rel="stylesheet" media="all" th:href="@{/theme-assets/umass/css/css_G5ztZKRblBn54t8h9F_EK5Y3CsZrMvA2BWPMI5jJoO4.css}">
|
<link rel="stylesheet" media="all" th:href="@{/theme-assets/umass/css/css_G5ztZKRblBn54t8h9F_EK5Y3CsZrMvA2BWPMI5jJoO4.css}" />
|
||||||
|
|
||||||
|
<!-- 🟢 1. SWIPER.JS CSS (Đã thêm) -->
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/swiper@11/swiper-bundle.min.css" />
|
||||||
|
|
||||||
<!-- Custom CSS -->
|
<!-- Custom CSS -->
|
||||||
<link rel="stylesheet" th:href="@{/css/custom.css}">
|
<link rel="stylesheet" th:href="@{/css/custom.css}" />
|
||||||
<!-- Theme Customizer CSS Output -->
|
<!-- Theme Customizer CSS Output -->
|
||||||
<style th:utext="${themeCss}"></style>
|
<style th:utext="${themeCss}"></style>
|
||||||
<!-- wp_head hook -->
|
<!-- wp_head hook -->
|
||||||
<th:block th:utext="${hookManager.doActionAndReturn('wp_head')}"></th:block>
|
<th:block th:utext="${hookManager.doActionAndReturn('wp_head')}"></th:block>
|
||||||
</head>
|
</head>
|
||||||
<body th:class="${(bodyClass != null ? bodyClass : '') + ((page != null and page.pageType != null and page.pageType.name() == 'HOME') ? ' umass-platform-homepage path-frontpage page-node-type-homepage homepage transparent-header' : '')}">
|
<body
|
||||||
|
th:class="${(bodyClass != null ? bodyClass : '') + ((page != null and page.pageType != null and page.pageType.name() == 'HOME') ? ' umass-platform-homepage path-frontpage page-node-type-homepage homepage transparent-header' : '')}"
|
||||||
|
>
|
||||||
<div class="dialog-off-canvas-main-canvas" data-off-canvas-main-canvas="">
|
<div class="dialog-off-canvas-main-canvas" data-off-canvas-main-canvas="">
|
||||||
<!-- Include Header Fragment -->
|
<!-- Include Header Fragment -->
|
||||||
<header th:replace="~{themes/__${activeTheme}__/header :: header}"></header>
|
<header th:replace="~{themes/__${activeTheme}__/header :: header}"></header>
|
||||||
@@ -29,21 +35,37 @@
|
|||||||
<div class="content">
|
<div class="content">
|
||||||
<div class="region region-content r--region r--content">
|
<div class="region region-content r--region r--content">
|
||||||
<!-- Content Area -->
|
<!-- Content Area -->
|
||||||
<div class="responsive-flex-container" th:with="isFullWidth=${(forceFullWidth != null and forceFullWidth) or (page != null and page.layout != null and page.layout.name() == 'FULL_WIDTH') or (post != null and post.layout != null and post.layout.name() == 'FULL_WIDTH')}, hasWidgets=${sidebarWidgets != null and !sidebarWidgets.empty}, showSidebar=${!isFullWidth and hasWidgets}" th:style="${isFullWidth} ? 'min-height: 400px;' : 'display: flex; min-height: 400px; padding: 20px; max-width: 1400px; margin: 0 auto;'">
|
<div
|
||||||
<div class="responsive-flex-content" th:style="${showSidebar} ? 'flex: 3; padding-right: 20px;' : 'width: 100%;'" layout:fragment="content"></div>
|
class="responsive-flex-container"
|
||||||
<aside class="widget-sidebar-area" th:if="${showSidebar}" style="flex: 1; padding: 15px; border-radius: 5px;">
|
th:with="isFullWidth=${(forceFullWidth != null and forceFullWidth) or (page != null and page.layout != null and page.layout.name() == 'FULL_WIDTH') or (post != null and post.layout != null and post.layout.name() == 'FULL_WIDTH')}, hasWidgets=${sidebarWidgets != null and !sidebarWidgets.empty}, showSidebar=${!isFullWidth and hasWidgets}"
|
||||||
|
th:style="${isFullWidth} ? 'min-height: 400px;' : 'display: flex; min-height: 400px; padding: 20px; max-width: 1400px; margin: 0 auto;'"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="responsive-flex-content"
|
||||||
|
th:style="${showSidebar} ? 'flex: 3; padding-right: 20px;' : 'width: 100%;'"
|
||||||
|
layout:fragment="content"
|
||||||
|
></div>
|
||||||
|
<aside class="widget-sidebar-area" th:if="${showSidebar}" style="flex: 1; padding: 15px; border-radius: 5px">
|
||||||
<h4>Sidebar</h4>
|
<h4>Sidebar</h4>
|
||||||
<div th:each="widget : ${sidebarWidgets}" style="margin-bottom: 20px;">
|
<div th:each="widget : ${sidebarWidgets}" style="margin-bottom: 20px">
|
||||||
<h5 th:text="${widget.title}" style="border-bottom: 1px solid #ddd; padding-bottom: 5px;">Widget Title</h5>
|
<h5 th:text="${widget.title}" style="border-bottom: 1px solid #ddd; padding-bottom: 5px">Widget Title</h5>
|
||||||
<div th:if="${widget.type == 'HTML'}" th:utext="${widget.content}"></div>
|
<div th:if="${widget.type == 'HTML'}" th:utext="${widget.content}"></div>
|
||||||
<div th:if="${widget.type == 'TEXT'}" th:text="${widget.content}"></div>
|
<div th:if="${widget.type == 'TEXT'}" th:text="${widget.content}"></div>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
<style>
|
<style>
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.widget-sidebar-area { display: none !important; }
|
.widget-sidebar-area {
|
||||||
.responsive-flex-container { flex-direction: column !important; padding: 10px !important; }
|
display: none !important;
|
||||||
.responsive-flex-content { padding-right: 0 !important; width: 100% !important; }
|
}
|
||||||
|
.responsive-flex-container {
|
||||||
|
flex-direction: column !important;
|
||||||
|
padding: 10px !important;
|
||||||
|
}
|
||||||
|
.responsive-flex-content {
|
||||||
|
padding-right: 0 !important;
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</div>
|
</div>
|
||||||
@@ -58,6 +80,11 @@
|
|||||||
<script th:src="@{/theme-assets/umass/js/js_aQtUyeGxehNR84AlGzGB1VfMu1Wn3lqHxvL8rocj1EU.js}"></script>
|
<script th:src="@{/theme-assets/umass/js/js_aQtUyeGxehNR84AlGzGB1VfMu1Wn3lqHxvL8rocj1EU.js}"></script>
|
||||||
<script th:src="@{/js/mobile-menu.js}"></script>
|
<script th:src="@{/js/mobile-menu.js}"></script>
|
||||||
<script th:src="@{/js/ambient-video.js}"></script>
|
<script th:src="@{/js/ambient-video.js}"></script>
|
||||||
|
<script th:src="@{/js/main.js}"></script>
|
||||||
|
|
||||||
|
<!-- 🟢 2. SWIPER.JS SCRIPT (Đã thêm) -->
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/swiper@11/swiper-bundle.min.js"></script>
|
||||||
|
|
||||||
<!-- wp_footer hook -->
|
<!-- wp_footer hook -->
|
||||||
<th:block th:utext="${hookManager.doActionAndReturn('wp_footer')}"></th:block>
|
<th:block th:utext="${hookManager.doActionAndReturn('wp_footer')}"></th:block>
|
||||||
<!-- Page Scripts Fragment -->
|
<!-- Page Scripts Fragment -->
|
||||||
|
|||||||
|
After Width: | Height: | Size: 131 KiB |
|
After Width: | Height: | Size: 131 KiB |
|
After Width: | Height: | Size: 131 KiB |
|
After Width: | Height: | Size: 487 KiB |
|
After Width: | Height: | Size: 487 KiB |
|
After Width: | Height: | Size: 487 KiB |
|
After Width: | Height: | Size: 300 KiB |
|
After Width: | Height: | Size: 300 KiB |
|
After Width: | Height: | Size: 300 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 62 KiB |