feat: integrate Swiper.js, update media persistence, and enhance page metadata support
@@ -1,15 +1,6 @@
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"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",
|
||||
"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).
|
||||
|
||||
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
|
||||
public SecurityFilterChain filterChain(HttpSecurity http, CustomOidcUserService customOidcUserService) {
|
||||
http.cors(withDefaults())
|
||||
.csrf(withDefaults())
|
||||
.csrf(csrf -> csrf.ignoringRequestMatchers("/api/manage/media/upload"))
|
||||
.authorizeHttpRequests(authz ->
|
||||
// prettier-ignore
|
||||
authz
|
||||
|
||||
@@ -60,7 +60,7 @@ public class ManagePostController {
|
||||
@RequestParam(value = "status", required = false) String statusStr,
|
||||
@RequestParam(value = "search", required = false) String search,
|
||||
@RequestParam(value = "page", defaultValue = "0") int page,
|
||||
@RequestParam(value = "size", defaultValue = "10") int size,
|
||||
@RequestParam(value = "size", defaultValue = "10000") int size,
|
||||
Model model) {
|
||||
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.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
@@ -69,4 +70,9 @@ public class MediaController {
|
||||
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;
|
||||
|
||||
@Column(name = "schedule_date", length = 50, nullable = false)
|
||||
private String date;
|
||||
private String scheduleDate;
|
||||
|
||||
@Column(name = "day_of_week", nullable = false)
|
||||
private Integer dayOfWeek;
|
||||
@@ -47,12 +47,20 @@ public class DoctorSchedule implements Serializable {
|
||||
this.doctor = doctor;
|
||||
}
|
||||
|
||||
public String getScheduleDate() {
|
||||
return scheduleDate;
|
||||
}
|
||||
|
||||
public void setScheduleDate(String scheduleDate) {
|
||||
this.scheduleDate = scheduleDate;
|
||||
}
|
||||
|
||||
public String getDate() {
|
||||
return date;
|
||||
return scheduleDate;
|
||||
}
|
||||
|
||||
public void setDate(String date) {
|
||||
this.date = date;
|
||||
this.scheduleDate = date;
|
||||
}
|
||||
|
||||
public Integer getDayOfWeek() {
|
||||
|
||||
@@ -5,6 +5,8 @@ import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import java.io.Serial;
|
||||
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).
|
||||
@@ -73,6 +75,14 @@ public class Page extends AbstractAuditingEntity<Long> {
|
||||
@Column(name = "specialty_category", length = 50)
|
||||
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 ---
|
||||
|
||||
@Override
|
||||
@@ -100,6 +110,14 @@ public class Page extends AbstractAuditingEntity<Long> {
|
||||
this.slug = slug;
|
||||
}
|
||||
|
||||
public Set<Tag> getTags() {
|
||||
return tags;
|
||||
}
|
||||
|
||||
public void setTags(Set<Tag> tags) {
|
||||
this.tags = tags;
|
||||
}
|
||||
|
||||
public String getContent() {
|
||||
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.hook.HookManager;
|
||||
|
||||
@@ -20,12 +20,23 @@ public class SwiperSliderAdminController {
|
||||
private final SettingService settingService;
|
||||
private final AdminContext adminContext;
|
||||
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";
|
||||
|
||||
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.adminContext = adminContext;
|
||||
this.objectMapper = objectMapper;
|
||||
this.categoryService = categoryService;
|
||||
this.tagService = tagService;
|
||||
this.componentTemplateService = componentTemplateService;
|
||||
}
|
||||
|
||||
private List<SliderGroup> getAllGroups() {
|
||||
@@ -68,6 +79,9 @@ public class SwiperSliderAdminController {
|
||||
model.addAttribute("groupJson", "{\"slug\":\"\", \"name\":\"\", \"layoutType\":\"\", \"outerTemplate\":\"\", \"itemTemplate\":\"\", \"items\":[]}");
|
||||
model.addAttribute("isNew", true);
|
||||
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";
|
||||
}
|
||||
@@ -92,6 +106,9 @@ public class SwiperSliderAdminController {
|
||||
model.addAttribute("oldSlug", slug);
|
||||
model.addAttribute("isNew", false);
|
||||
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";
|
||||
}
|
||||
@@ -151,11 +168,35 @@ public class SwiperSliderAdminController {
|
||||
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 getName() { return name; }
|
||||
public String getLayoutType() { return layoutType; }
|
||||
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 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 SettingService settingService;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final jakarta.persistence.EntityManager entityManager;
|
||||
|
||||
public SwiperSliderPlugin(PluginRepository pluginRepository,
|
||||
ComponentTemplateRepository templateRepository,
|
||||
HookManager hookManager,
|
||||
SettingService settingService,
|
||||
ObjectMapper objectMapper) {
|
||||
ObjectMapper objectMapper,
|
||||
jakarta.persistence.EntityManager entityManager) {
|
||||
this.pluginRepository = pluginRepository;
|
||||
this.templateRepository = templateRepository;
|
||||
this.hookManager = hookManager;
|
||||
this.settingService = settingService;
|
||||
this.objectMapper = objectMapper;
|
||||
this.entityManager = entityManager;
|
||||
}
|
||||
|
||||
@EventListener(ApplicationReadyEvent.class)
|
||||
@@ -76,7 +79,6 @@ public class SwiperSliderPlugin {
|
||||
LOG.info("SwiperSliderPlugin is active. Seeding components and hooks...");
|
||||
|
||||
seedComponentTemplate();
|
||||
seedDefaultData();
|
||||
|
||||
// Hook to add menu to Plugins dropdown
|
||||
hookManager.addFilter("admin_menu_plugins", (content, args) -> {
|
||||
@@ -115,7 +117,7 @@ public class SwiperSliderPlugin {
|
||||
return text;
|
||||
}
|
||||
|
||||
Pattern pattern = Pattern.compile("\\[plugin:swiper-slider slug=\"([^\"]+)\"\\]");
|
||||
Pattern pattern = Pattern.compile("\\[plugin:swiper-slider slug=[\"']?([^\"'\\] ]+)[\"']?\\]");
|
||||
Matcher matcher = pattern.matcher(text);
|
||||
StringBuilder result = new StringBuilder();
|
||||
|
||||
@@ -132,71 +134,9 @@ public class SwiperSliderPlugin {
|
||||
String json = settingService.getValue(SETTING_KEY, "[]");
|
||||
List<SwiperSliderAdminController.SliderGroup> groups = parseGroups(json);
|
||||
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()))
|
||||
.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) {
|
||||
@@ -215,7 +155,8 @@ public class SwiperSliderPlugin {
|
||||
}
|
||||
|
||||
SwiperSliderAdminController.SliderGroup group = groupOpt.get();
|
||||
if (group.items() == null || group.items().isEmpty()) {
|
||||
List<SwiperSliderAdminController.SliderItem> dynamicItems = fetchItemsForGroup(group);
|
||||
if (dynamicItems.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
|
||||
@@ -241,7 +182,8 @@ public class SwiperSliderPlugin {
|
||||
}
|
||||
|
||||
SwiperSliderAdminController.SliderGroup group = groupOpt.get();
|
||||
if (group.items() == null || group.items().isEmpty()) {
|
||||
List<SwiperSliderAdminController.SliderItem> dynamicItems = fetchItemsForGroup(group);
|
||||
if (dynamicItems.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
|
||||
@@ -259,12 +201,14 @@ public class SwiperSliderPlugin {
|
||||
slideTemplate = getSlideTemplateForSlug("course-card");
|
||||
} else if ("custom".equals(layoutType) && group.itemTemplate() != null && !group.itemTemplate().trim().isEmpty()) {
|
||||
slideTemplate = group.itemTemplate();
|
||||
} else if (layoutType != null && !layoutType.trim().isEmpty()) {
|
||||
slideTemplate = getSlideTemplateForSlug(layoutType);
|
||||
} else {
|
||||
// Fallback to legacy slug-based template if no matching layoutType or empty custom template
|
||||
slideTemplate = getSlideTemplateForSlug(slug);
|
||||
}
|
||||
|
||||
for (SwiperSliderAdminController.SliderItem item : group.items()) {
|
||||
for (SwiperSliderAdminController.SliderItem item : dynamicItems) {
|
||||
String slide = slideTemplate
|
||||
.replace("{{title}}", item.title() != null ? item.title() : "")
|
||||
.replace("{{imageUrl}}", item.imageUrl() != null ? item.imageUrl() : "")
|
||||
@@ -278,19 +222,78 @@ public class SwiperSliderPlugin {
|
||||
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) {
|
||||
if ("gallery".equals(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 ("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>";
|
||||
Optional<com.sisvietnamvn.web.domain.ComponentTemplate> optTpl = templateRepository.findBySlug("swiper-slide-" + slug);
|
||||
if (optTpl.isPresent()) {
|
||||
return optTpl.get().getHtmlTemplate();
|
||||
}
|
||||
|
||||
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 "";
|
||||
}
|
||||
|
||||
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() {
|
||||
java.util.Optional<com.sisvietnamvn.web.domain.ComponentTemplate> opt = templateRepository.findBySlug("swiper-slider");
|
||||
@@ -341,6 +319,25 @@ public class SwiperSliderPlugin {
|
||||
templateRepository.save(t);
|
||||
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() {
|
||||
@@ -352,7 +349,7 @@ public class SwiperSliderPlugin {
|
||||
<div class="swiper swiper-{{slug}} w-full swiper-backface-hidden">
|
||||
<div class="swiper-wrapper">
|
||||
{{#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">
|
||||
<img src="{{imageUrl}}" alt="{{title}}" class="absolute inset-0 w-full h-full object-cover">
|
||||
</div>
|
||||
|
||||
@@ -28,6 +28,11 @@ public interface PageRepository extends JpaRepository<Page, Long> {
|
||||
*/
|
||||
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.
|
||||
*/
|
||||
|
||||
@@ -59,6 +59,7 @@ public class MediaService {
|
||||
Media media = new Media();
|
||||
media.setFileUrl(fileUrl);
|
||||
media.setOriginalFilename(filename);
|
||||
media.setStoredFilename(filename);
|
||||
media.setMediaType(MediaType.IMAGE); // Assume image for now
|
||||
|
||||
mediaRepository.save(media);
|
||||
|
||||
@@ -67,7 +67,7 @@ public class PageService {
|
||||
@Transactional(readOnly = true)
|
||||
public Optional<Page> findByPageType(com.sisvietnamvn.web.domain.PageType pageType) {
|
||||
LOG.debug("Request to get Page by type : {}", pageType);
|
||||
return pageRepository.findByPageType(pageType);
|
||||
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/20260728166000_update_active_doctors.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>
|
||||
|
||||
@@ -1192,6 +1192,54 @@ figure.table table tr:hover {
|
||||
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 th {
|
||||
background-color: var(--color-old-brick, #881c1c) !important;
|
||||
@@ -1219,10 +1267,17 @@ figure.table table tr:hover {
|
||||
|
||||
.flex {
|
||||
display: flex !important;
|
||||
.h-stretch {
|
||||
height: stretch;
|
||||
}
|
||||
}
|
||||
.flex-col {
|
||||
flex-direction: column;
|
||||
}
|
||||
.flex-row {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.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 {
|
||||
border: solid 1px #efeff0;
|
||||
border-radius: 15px;
|
||||
padding: 1rem;
|
||||
height: stretch;
|
||||
.btn {
|
||||
padding: 0.75rem;
|
||||
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="">
|
||||
<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">
|
||||
<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}">
|
||||
@@ -61,8 +61,8 @@
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="xl:py-8 md:py-6 py-4">
|
||||
<div class="container xl:space-y-8 md:space-y-6 space-y-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 bg-[#f6f6f6]">
|
||||
<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;">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>
|
||||
</section>
|
||||
<section class="xl:py-12 md:py-8 py-6 bg-[#f6f6f6] 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="flex justify-between items-end md:mb-6 mb-4 lg:mb-8 max-lg:pr-5 max-md:px-4">
|
||||
<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 bg-white" th:if="${daoTaoPosts != null and not #lists.isEmpty(daoTaoPosts)}">
|
||||
<div class="md:mb-6 mb-4 lg:mb-8 max-lg:pr-5 max-md:px-4">
|
||||
<div>
|
||||
<h2 class="display-7 text-primary-600">Khóa học Đào tạo</h2>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex md:gap-2 gap-1">
|
||||
<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">
|
||||
<div>
|
||||
<div class="relative md:flex md:items-center md:gap-x-2">
|
||||
<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">
|
||||
<path d="m18 15-6-6-6 6"></path>
|
||||
</svg>
|
||||
</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">
|
||||
<path d="m6 9 6 6 6-6"></path>
|
||||
</svg>
|
||||
@@ -121,134 +126,88 @@
|
||||
</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>
|
||||
|
||||
<!-- 4. Gom Tiêu đề và Trích dẫn vào chung 1 thẻ flex-1 -->
|
||||
<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 class="lg:container max-lg:pl-5 max-md:px-0 bg-[#f6f6f6]">
|
||||
<div class="xl:py-12 md:py-8 py-6 xl:space-y-12 md:space-y-8 space-y-6">
|
||||
<div>
|
||||
<h2 class="display-7 text-primary-600">Cấp chứng chỉ</h2>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex md:gap-2 gap-1">
|
||||
<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">
|
||||
<path d="m18 15-6-6-6 6"></path>
|
||||
</svg>
|
||||
</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">
|
||||
<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 class="flex md:gap-2 gap-1 items-center">
|
||||
<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">
|
||||
<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>
|
||||
</svg>
|
||||
</button>
|
||||
<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">
|
||||
<path d="m6 9 6 6 6-6"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative max-md:px-4 md:mb-6 mb-4 lg:mb-8">
|
||||
<div class="swiper swiper-chung-chi w-full">
|
||||
<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 class="lg:container max-lg:pl-5 max-md:px-0 bg-white">
|
||||
<div>
|
||||
<div>
|
||||
<h2 class="display-7 text-primary-600">Cấp giấy chứng nhận</h2>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex md:gap-2 gap-1">
|
||||
<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">
|
||||
<path d="m18 15-6-6-6 6"></path>
|
||||
</svg>
|
||||
</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">
|
||||
<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-giay-chung-nhan w-full">
|
||||
<div class="swiper-wrapper">
|
||||
<th:block th:utext="${hookManager.applyFilters('swiper_slider_items', '', 'giay-chung-nhan')}"></th:block>
|
||||
<div class="flex md:gap-2 gap-1 items-center">
|
||||
<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">
|
||||
<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>
|
||||
</svg>
|
||||
</button>
|
||||
<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">
|
||||
<path d="m6 9 6 6 6-6"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</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 class="lg:container max-lg:pl-5 max-md:px-0 bg-[#f6f6f6]">
|
||||
<div class="xl:py-12 md:py-8 py-6 xl:space-y-12 md:space-y-8 space-y-6">
|
||||
<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>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex md:gap-2 gap-1">
|
||||
<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">
|
||||
<path d="m18 15-6-6-6 6"></path>
|
||||
</svg>
|
||||
</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">
|
||||
<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">
|
||||
<th:block th:utext="${hookManager.applyFilters('swiper_slider_items', '', 'xac-nhan-thuc-hanh')}"></th:block>
|
||||
<div class="flex md:gap-2 gap-1 items-center">
|
||||
<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">
|
||||
<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>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="swiper w-full swiper-xac-nhan-thuc-hanh">
|
||||
<div class="swiper-wrapper">
|
||||
<th:block th:utext="${hookManager.applyFilters('swiper_slider_items', '', 'xac-nhan-thuc-hanh')}"></th:block>
|
||||
</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>
|
||||
</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="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="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">
|
||||
<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>
|
||||
<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>
|
||||
@@ -260,7 +219,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</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="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">
|
||||
@@ -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>
|
||||
</div>
|
||||
</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="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}">
|
||||
@@ -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>
|
||||
</div>
|
||||
<div class="xl:col-span-8 lg:col-span-7 col-span-full">
|
||||
<div class="relative">
|
||||
<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">
|
||||
<div class="flex flex-row h-full items-center">
|
||||
<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">
|
||||
<path d="m18 15-6-6-6 6"></path>
|
||||
</svg>
|
||||
</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">
|
||||
<th:block th:utext="${hookManager.applyFilters('swiper_slider_items', '', 'hinh-anh-thuc-hanh')}"></th:block>
|
||||
</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">
|
||||
<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 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>
|
||||
<th:block th:utext="${hookManager.applyFilters('swiper_slider_items', '', 'dao-tao-lam-sang-01')}"></th:block>
|
||||
</div>
|
||||
</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">
|
||||
<path d="m6 9 6 6 6-6"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -323,85 +278,7 @@
|
||||
</svg>
|
||||
</button>
|
||||
</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">
|
||||
<script>
|
||||
if (typeof initDaoTaoSliders === 'function') {
|
||||
initDaoTaoSliders();
|
||||
}
|
||||
</script>
|
||||
</th:block>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,52 +1,58 @@
|
||||
<!DOCTYPE html>
|
||||
<!doctype html>
|
||||
<html lang="en" xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>UMass Amherst</title>
|
||||
|
||||
<!-- Google Fonts: Lora and Open Sans -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<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"
|
||||
rel="stylesheet">
|
||||
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"
|
||||
/>
|
||||
|
||||
<!-- Add base CSS here if available -->
|
||||
<th:block layout:fragment="head"></th:block>
|
||||
|
||||
<!-- 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) -->
|
||||
<link rel="stylesheet" th:href="@{/css/custom.css}">
|
||||
</head>
|
||||
<link rel="stylesheet" th:href="@{/css/custom.css}" />
|
||||
</head>
|
||||
|
||||
<body th:class="${bodyClass}">
|
||||
<body th:class="${bodyClass}">
|
||||
<!-- Skip to main content link (Accessibility) -->
|
||||
<a href="#main-content" class="visually-hidden focusable skip-link">Skip to main content</a>
|
||||
|
||||
<div class="dialog-off-canvas-main-canvas" data-off-canvas-main-canvas="">
|
||||
<!-- Header Fragment -->
|
||||
<div th:replace="~{themes/__${activeTheme}__/header :: header}"></div>
|
||||
<!-- Header Fragment -->
|
||||
<div th:replace="~{themes/__${activeTheme}__/header :: header}"></div>
|
||||
|
||||
<!-- Main Content Block -->
|
||||
<main id="main-content" role="main">
|
||||
<div layout:fragment="content">
|
||||
<!-- All elements will be defined in the editor page -->
|
||||
</div>
|
||||
</main>
|
||||
<!-- Main Content Block -->
|
||||
<main id="main-content" role="main">
|
||||
<div layout:fragment="content">
|
||||
<!-- All elements will be defined in the editor page -->
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Footer Fragment -->
|
||||
<div th:replace="~{themes/__${activeTheme}__/footer :: footer}"></div>
|
||||
<!-- Footer Fragment -->
|
||||
<div th:replace="~{themes/__${activeTheme}__/footer :: footer}"></div>
|
||||
</div>
|
||||
|
||||
<!-- 🟢 2. SWIPER.JS SCRIPT (Đã thêm) -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/swiper@11/swiper-bundle.min.js"></script>
|
||||
|
||||
<!-- Core Scripts -->
|
||||
<script th:src="@{/js/mobile-menu.js}"></script>
|
||||
<script th:src="@{/js/main.js}"></script>
|
||||
|
||||
<!-- Page Specific Scripts -->
|
||||
<th:block layout:fragment="scripts"></th:block>
|
||||
</body>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,110 +1,339 @@
|
||||
<html lang="en" xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" layout:decorate="~{fragments/manage-layout}">
|
||||
<head>
|
||||
<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; }
|
||||
.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">
|
||||
</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>
|
||||
<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}" />
|
||||
<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-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 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 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 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="card-body">
|
||||
<div id="itemsContainer"></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>
|
||||
<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>
|
||||
</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>
|
||||
<option value="MEDIA">Thư viện ảnh (Media Library)</option>
|
||||
</select>
|
||||
</div>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<button type="submit" class="btn btn-primary btn-lg mb-4" onclick="return saveGroup()">Lưu Nhóm Slider</button>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<script th:inline="javascript">
|
||||
<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: []}; }
|
||||
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 || '');
|
||||
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 || '';
|
||||
|
||||
const container = document.getElementById('itemsContainer');
|
||||
container.innerHTML = '';
|
||||
toggleCustomTemplates(group.layoutType || '');
|
||||
toggleDataSource(group.dataSource || 'MANUAL');
|
||||
|
||||
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;
|
||||
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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
group.items.forEach((item, iIndex) => {
|
||||
const itemHtml = `
|
||||
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');
|
||||
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>
|
||||
@@ -134,80 +363,181 @@
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
container.insertAdjacentHTML('beforeend', itemHtml);
|
||||
});
|
||||
container.insertAdjacentHTML('beforeend', itemHtml);
|
||||
});
|
||||
}
|
||||
|
||||
function toggleCustomTemplates(layoutType) {
|
||||
const display = layoutType === 'custom' ? 'block' : 'none';
|
||||
document.querySelectorAll('.custom-template-container').forEach(el => el.style.display = display);
|
||||
const display = layoutType === 'custom' ? 'block' : 'none';
|
||||
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) {
|
||||
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() {
|
||||
group.items.push({title: '', imageUrl: '', linkUrl: '', description: ''});
|
||||
renderGroup();
|
||||
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();
|
||||
}
|
||||
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;
|
||||
group.items[iIndex][field] = value;
|
||||
}
|
||||
|
||||
async function uploadImage(iIndex, file) {
|
||||
if (!file) return;
|
||||
const formData = new FormData();
|
||||
formData.append('file', 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;
|
||||
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
|
||||
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;
|
||||
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();
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
renderGroup();
|
||||
});
|
||||
</script>
|
||||
</div>
|
||||
</body>
|
||||
</script>
|
||||
</div>
|
||||
</body>
|
||||
</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">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta charset="UTF-8" />
|
||||
<title>UMass Amherst Theme</title>
|
||||
<!-- UMass Amherst Assets -->
|
||||
<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 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_G5ztZKRblBn54t8h9F_EK5Y3CsZrMvA2BWPMI5jJoO4.css}">
|
||||
<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 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_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 -->
|
||||
<link rel="stylesheet" th:href="@{/css/custom.css}">
|
||||
<link rel="stylesheet" th:href="@{/css/custom.css}" />
|
||||
<!-- Theme Customizer CSS Output -->
|
||||
<style th:utext="${themeCss}"></style>
|
||||
<!-- wp_head hook -->
|
||||
<th:block th:utext="${hookManager.doActionAndReturn('wp_head')}"></th:block>
|
||||
</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="">
|
||||
<!-- Include Header Fragment -->
|
||||
<header th:replace="~{themes/__${activeTheme}__/header :: header}"></header>
|
||||
@@ -29,21 +35,37 @@
|
||||
<div class="content">
|
||||
<div class="region region-content r--region r--content">
|
||||
<!-- 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 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;">
|
||||
<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
|
||||
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>
|
||||
<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>
|
||||
<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>
|
||||
<div th:if="${widget.type == 'HTML'}" th:utext="${widget.content}"></div>
|
||||
<div th:if="${widget.type == 'TEXT'}" th:text="${widget.content}"></div>
|
||||
</div>
|
||||
</aside>
|
||||
<style>
|
||||
@media (max-width: 768px) {
|
||||
.widget-sidebar-area { display: none !important; }
|
||||
.responsive-flex-container { flex-direction: column !important; padding: 10px !important; }
|
||||
.responsive-flex-content { padding-right: 0 !important; width: 100% !important; }
|
||||
.widget-sidebar-area {
|
||||
display: none !important;
|
||||
}
|
||||
.responsive-flex-container {
|
||||
flex-direction: column !important;
|
||||
padding: 10px !important;
|
||||
}
|
||||
.responsive-flex-content {
|
||||
padding-right: 0 !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</div>
|
||||
@@ -58,6 +80,11 @@
|
||||
<script th:src="@{/theme-assets/umass/js/js_aQtUyeGxehNR84AlGzGB1VfMu1Wn3lqHxvL8rocj1EU.js}"></script>
|
||||
<script th:src="@{/js/mobile-menu.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 -->
|
||||
<th:block th:utext="${hookManager.doActionAndReturn('wp_footer')}"></th:block>
|
||||
<!-- 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 |