forked from UKSOURCE/cms.hailearning.edu.vn
1210 lines
54 KiB
Plaintext
1210 lines
54 KiB
Plaintext
<div class="container">
|
|
<div class="d-flex justify-content-between align-items-center mt-4 mb-4">
|
|
<div>
|
|
<h1 class="h3 mb-2" style="color: var(--primary-dark);">
|
|
Terms & Conditions Editor
|
|
</h1>
|
|
<p class="text-muted mb-0">
|
|
<a href="/admin/info" class="text-decoration-none">
|
|
<i class="fas fa-arrow-left me-1"></i>Back to Information Pages
|
|
</a>
|
|
</p>
|
|
</div>
|
|
<div class="d-flex gap-2">
|
|
<button type="submit" form="termsForm" class="btn btn-primary" id="saveBtn">
|
|
<i class="fas fa-save me-2"></i>Save Changes
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<form id="termsForm" action="/admin/terms/update" method="POST" class="needs-validation" novalidate>
|
|
<!-- Hidden inputs -->
|
|
<input type="hidden" name="hero" id="heroJson">
|
|
<input type="hidden" name="page" id="pageJson">
|
|
<input type="hidden" name="content" id="contentJson">
|
|
|
|
<div class="row">
|
|
<!-- Main Content Column -->
|
|
<div class="col-lg-8">
|
|
<!-- Page Information Card -->
|
|
<div class="card shadow-sm border-0 mb-4">
|
|
<div class="card-header bg-white py-3">
|
|
<h5 class="card-title mb-0">Page Information</h5>
|
|
</div>
|
|
<div class="card-body">
|
|
<div class="mb-3">
|
|
<label class="form-label">Page Title <span class="text-danger">*</span></label>
|
|
<textarea class="form-control" id="pageTitle" rows="2" required><%= data.page?.title || 'Terms & Conditions Go and Grow Camp e.K.' %></textarea>
|
|
<div class="invalid-feedback">Please enter a page title</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Hero Section Card -->
|
|
<div class="card shadow-sm border-0 mb-4">
|
|
<div class="card-header bg-white py-3">
|
|
<h5 class="card-title mb-0">Hero Section</h5>
|
|
</div>
|
|
<div class="card-body">
|
|
<div class="row">
|
|
<div class="col-md-5">
|
|
<div class="mb-3">
|
|
<label class="form-label">Background Image</label>
|
|
<div class="input-group">
|
|
<input type="text" class="form-control" id="heroBackgroundImage"
|
|
value="<%= data.hero?.backgroundImage || '/uploads/terms/faqimage.jpg' %>" readonly>
|
|
<button type="button" class="btn btn-outline-primary btn-upload-image"
|
|
data-target-input="heroBackgroundImage" data-image-type="terms">
|
|
<i class="fas fa-upload"></i>
|
|
</button>
|
|
</div>
|
|
<small class="form-text text-muted">Recommended size: 1920x1080px</small>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-7">
|
|
<div id="heroImagePreview" style="height: 200px; width: 100%;">
|
|
<% if (data.hero?.backgroundImage) { %>
|
|
<%
|
|
let heroImgSrc = data.hero.backgroundImage;
|
|
if (heroImgSrc && !heroImgSrc.startsWith('http://') && !heroImgSrc.startsWith('https://')) {
|
|
heroImgSrc = heroImgSrc.startsWith('/') ? heroImgSrc : '/' + heroImgSrc;
|
|
}
|
|
%>
|
|
<img src="<%= heroImgSrc %>" class="img-thumbnail"
|
|
style="height: 200px; width: 100%; object-fit: cover;"
|
|
alt="Background image preview"
|
|
onerror="this.style.display='none'; this.nextElementSibling.style.display='flex';">
|
|
<div class="border rounded p-5 text-center text-muted"
|
|
style="height: 200px; display: none; align-items: center; justify-content: center;">
|
|
Image preview
|
|
</div>
|
|
<% } else { %>
|
|
<div class="border rounded p-5 text-center text-muted"
|
|
style="height: 200px; display: flex; align-items: center; justify-content: center;">
|
|
No image selected
|
|
</div>
|
|
<% } %>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="row mt-3">
|
|
<div class="col-md-12">
|
|
<label class="form-label">Hero Title</label>
|
|
<input type="text" class="form-control" id="heroTitle"
|
|
value="<%= data.hero?.title || 'Go and Grow Camp' %>">
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Content Editor Section -->
|
|
<div class="card shadow-sm border-0">
|
|
<div class="card-header bg-white py-3">
|
|
<h5 class="card-title mb-0">Content Editor</h5>
|
|
<p class="text-muted mb-0 small">Write your Terms & Conditions content just like a blog post</p>
|
|
</div>
|
|
<div class="card-body">
|
|
<div id="editorjs" class="border rounded p-3" style="min-height: 500px;"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Sidebar Column -->
|
|
<div class="col-lg-4">
|
|
<!-- SEO Settings Card -->
|
|
<div class="card shadow-sm border-0 mb-4">
|
|
<div class="card-header bg-white py-3">
|
|
<h5 class="card-title mb-0">SEO Settings</h5>
|
|
</div>
|
|
<div class="card-body">
|
|
<div class="mb-3">
|
|
<label class="form-label">Meta Title</label>
|
|
<input type="text" class="form-control" id="metadataTitle"
|
|
value="<%= data.page?.metadata?.title || '' %>">
|
|
<small class="text-muted">Title for search engines (max 60 characters)</small>
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label">Meta Description</label>
|
|
<textarea class="form-control" id="metadataDescription" rows="3"
|
|
placeholder="Meta description for SEO"><%= data.page?.metadata?.description || '' %></textarea>
|
|
<small class="text-muted">Description for search engines (max 160 characters)</small>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Page Settings Card -->
|
|
<div class="card shadow-sm border-0 mb-4">
|
|
<div class="card-header bg-white py-3">
|
|
<h5 class="card-title mb-0">Page Settings</h5>
|
|
</div>
|
|
<div class="card-body">
|
|
<div class="mb-3">
|
|
<label class="form-label">Last Updated Date</label>
|
|
<input type="date" class="form-control" id="footerNoteLastUpdated"
|
|
value="<%= data.footerNote?.lastUpdated || '' %>">
|
|
</div>
|
|
<div class="mb-3">
|
|
<label class="form-label">Footer Text</label>
|
|
<input type="text" class="form-control" id="footerNoteText"
|
|
value="<%= data.footerNote?.text || '' %>"
|
|
placeholder="e.g., Last updated on...">
|
|
</div>
|
|
<div class="mb-3">
|
|
<div class="form-check form-switch">
|
|
<input class="form-check-input" type="checkbox" id="enableScrollspy"
|
|
<%= data.layout?.enableScrollspy ? 'checked' : '' %>>
|
|
<label class="form-check-label" for="enableScrollspy">Enable Scrollspy Navigation</label>
|
|
</div>
|
|
<small class="text-muted">Creates table of contents from headers</small>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Content Tips Card -->
|
|
<div class="card shadow-sm border-0">
|
|
<div class="card-header bg-white py-3">
|
|
<h5 class="card-title mb-0">Content Tips</h5>
|
|
</div>
|
|
<div class="card-body">
|
|
<div class="alert alert-info">
|
|
<h6><i class="fas fa-lightbulb me-2"></i>Tips for Terms & Conditions:</h6>
|
|
<ul class="mb-0 small">
|
|
<li>Use <strong>Header 2</strong> for main sections</li>
|
|
<li>Use <strong>Header 3</strong> for subsections</li>
|
|
<li>Use <strong>Lists</strong> for terms items</li>
|
|
<li>Use <strong>Conclusion</strong> tool for important notes</li>
|
|
<li>Use <strong>Quote</strong> for legal references</li>
|
|
</ul>
|
|
<hr class="my-2">
|
|
<h6><i class="fas fa-keyboard me-2"></i>Keyboard Shortcuts:</h6>
|
|
<ul class="mb-0 small">
|
|
<li><kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>H</kbd>: Convert list item to header</li>
|
|
<li><kbd>Tab</kbd> in list: Indent item</li>
|
|
<li><kbd>Backspace</kbd> at start: Exit list</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
|
|
<!-- Preview Modal -->
|
|
<div class="modal fade" id="previewModal" tabindex="-1">
|
|
<div class="modal-dialog modal-xl">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title">Terms & Conditions Preview</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
|
</div>
|
|
<div class="modal-body p-0">
|
|
<iframe id="previewFrame" style="width: 100%; height: 600px; border: none;"></iframe>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Hidden inputs for file upload -->
|
|
<input type="file" id="directImageUpload" style="display: none;">
|
|
<input type="hidden" id="currentImageType">
|
|
<input type="hidden" id="currentTargetInput">
|
|
|
|
<!-- Editor.js Dependencies -->
|
|
<script src="https://cdn.jsdelivr.net/npm/@editorjs/editorjs@2.28.2"></script>
|
|
<script src="https://cdn.jsdelivr.net/npm/@editorjs/header@2.7.0"></script>
|
|
<script src="https://cdn.jsdelivr.net/npm/@editorjs/paragraph@2.11.3"></script>
|
|
<script src="https://cdn.jsdelivr.net/npm/@editorjs/list@1.8.0"></script>
|
|
<script src="https://cdn.jsdelivr.net/npm/@editorjs/image@2.8.1"></script>
|
|
<script src="https://cdn.jsdelivr.net/npm/@editorjs/quote@2.5.0"></script>
|
|
<script src="https://cdn.jsdelivr.net/npm/@editorjs/marker@1.3.0"></script>
|
|
<script src="https://cdn.jsdelivr.net/npm/@editorjs/embed@2.5.3"></script>
|
|
|
|
<script>
|
|
// Terms Content Manager - Simple version
|
|
class TermsContentManager {
|
|
// Convert Editor.js data to Terms content structure
|
|
convertEditorToTerms(editorData) {
|
|
const contentItems = [];
|
|
let currentSection = null;
|
|
|
|
editorData.blocks.forEach(block => {
|
|
switch (block.type) {
|
|
case 'paragraph':
|
|
contentItems.push({
|
|
type: 'paragraph',
|
|
text: block.data.text
|
|
});
|
|
break;
|
|
|
|
case 'header':
|
|
// Xử lý các header levels khác nhau
|
|
const text = block.data.text;
|
|
const level = block.data.level || 2;
|
|
const isMainSection = /^\d+\./.test(text);
|
|
|
|
if (isMainSection) {
|
|
// Section chính (bắt đầu bằng số như "1.", "2.")
|
|
contentItems.push({
|
|
type: 'section',
|
|
title: text,
|
|
content: '', // Will be filled by following paragraphs
|
|
subsections: []
|
|
});
|
|
} else {
|
|
// Lưu header với level cụ thể (h2, h3, h4)
|
|
contentItems.push({
|
|
type: 'header',
|
|
level: level,
|
|
text: text
|
|
});
|
|
}
|
|
break;
|
|
|
|
case 'list':
|
|
const lastItem = contentItems[contentItems.length - 1];
|
|
|
|
// Check if this is a cancellation table
|
|
if (lastItem?.type === 'section' && block.data.items.some(item =>
|
|
item.toLowerCase().includes('cancellation') ||
|
|
item.toLowerCase().includes('fee') ||
|
|
item.toLowerCase().includes('standard')
|
|
)) {
|
|
// This might be a cancellation table
|
|
const isCancellationTable = block.data.items.some(item =>
|
|
item.toLowerCase().includes('days before')
|
|
);
|
|
|
|
if (isCancellationTable) {
|
|
lastItem.subsections = lastItem.subsections || [];
|
|
lastItem.subsections.push({
|
|
type: 'cancellation_table',
|
|
title: 'Standard Cancellation Fees',
|
|
items: block.data.items
|
|
});
|
|
} else if (block.data.items.some(item => item.toLowerCase().includes('school group'))) {
|
|
lastItem.subsections = lastItem.subsections || [];
|
|
lastItem.subsections.push({
|
|
type: 'cancellation_section',
|
|
title: 'Cancellation policy for school groups:',
|
|
items: block.data.items
|
|
});
|
|
} else {
|
|
// Regular list - store as list type with items array
|
|
contentItems.push({
|
|
type: 'list',
|
|
style: block.data.style || 'unordered',
|
|
items: block.data.items
|
|
});
|
|
}
|
|
} else {
|
|
// Regular list - store as list type with items array
|
|
contentItems.push({
|
|
type: 'list',
|
|
style: block.data.style || 'unordered',
|
|
items: block.data.items
|
|
});
|
|
}
|
|
break;
|
|
|
|
case 'quote':
|
|
contentItems.push({
|
|
type: 'note',
|
|
text: block.data.text
|
|
});
|
|
break;
|
|
|
|
case 'embed':
|
|
// store embed as separate item (YouTube support)
|
|
const source = block.data.source || block.data.embed || '';
|
|
const videoId = (source.match(/(?:youtu\.be\/|youtube\.com\/watch\?v=)([a-zA-Z0-9_-]{11})/) || [])[1]
|
|
|| (block.data.embed && (block.data.embed.match(/youtube\.com\/embed\/([a-zA-Z0-9_-]{11})/) || [])[1])
|
|
|| '';
|
|
contentItems.push({
|
|
type: 'embed',
|
|
url: source,
|
|
videoId: videoId,
|
|
title: block.data.title || '',
|
|
caption: block.data.caption || ''
|
|
});
|
|
break;
|
|
case 'image':
|
|
// Tạo URL đầy đủ nếu đường dẫn là tương đối
|
|
let imageUrl = block.data.file.url;
|
|
if (imageUrl && !imageUrl.startsWith('http')) {
|
|
// Thêm base URL nếu cần, tùy thuộc cấu hình của bạn
|
|
// imageUrl = '/uploads/' + imageUrl; // Ví dụ
|
|
}
|
|
|
|
// Tạo HTML cho ảnh với caption
|
|
let imgHtml = `<img src="${imageUrl}" alt="${block.data.caption || ''}" style="max-width: 100%; height: auto; border-radius: 8px;" />`;
|
|
if (block.data.caption) {
|
|
imgHtml += `<p style="text-align: center; font-size: 0.9em; color: #666; margin-top: 8px;">${block.data.caption}</p>`;
|
|
}
|
|
|
|
// Thêm ảnh vào content
|
|
if (currentSection) {
|
|
currentSection.content += (currentSection.content ? '<br/>' : '') + imgHtml;
|
|
} else {
|
|
contentItems.push({
|
|
type: 'image', // 👈 Tạo type mới cho ảnh
|
|
url: imageUrl,
|
|
caption: block.data.caption || '',
|
|
html: imgHtml
|
|
});
|
|
}
|
|
break;
|
|
}
|
|
});
|
|
|
|
// Combine section content from following paragraphs
|
|
const processedItems = [];
|
|
currentSection = null;
|
|
|
|
contentItems.forEach(item => {
|
|
if (item.type === 'section') {
|
|
if (currentSection) processedItems.push(currentSection);
|
|
currentSection = item;
|
|
} else if (currentSection && item.type === 'paragraph') {
|
|
// Add paragraph to section content
|
|
currentSection.content += (currentSection.content ? '<br/>' : '') + item.text;
|
|
} else {
|
|
if (currentSection) {
|
|
processedItems.push(currentSection);
|
|
currentSection = null;
|
|
}
|
|
processedItems.push(item);
|
|
}
|
|
});
|
|
|
|
if (currentSection) processedItems.push(currentSection);
|
|
|
|
return processedItems;
|
|
}
|
|
|
|
// Convert Terms content to Editor.js data
|
|
convertTermsToEditor(termsContent) {
|
|
const blocks = [];
|
|
|
|
termsContent.forEach(item => {
|
|
switch (item.type) {
|
|
case 'paragraph':
|
|
// Check if paragraph contains HTML list and convert back
|
|
if (item.text && item.text.includes('<ul>') || item.text && item.text.includes('<ol>')) {
|
|
// Extract list items from HTML
|
|
const listMatch = item.text.match(/<(ul|ol)>(.*?)<\/\1>/s);
|
|
if (listMatch) {
|
|
const listType = listMatch[1];
|
|
const itemsHtml = listMatch[2];
|
|
const items = [];
|
|
const liRegex = /<li>(.*?)<\/li>/g;
|
|
let match;
|
|
while ((match = liRegex.exec(itemsHtml)) !== null) {
|
|
items.push(match[1]);
|
|
}
|
|
|
|
if (items.length > 0) {
|
|
blocks.push({
|
|
type: 'list',
|
|
data: {
|
|
style: listType === 'ul' ? 'unordered' : 'ordered',
|
|
items: items
|
|
}
|
|
});
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
blocks.push({
|
|
type: 'paragraph',
|
|
data: {
|
|
text: item.text
|
|
}
|
|
});
|
|
break;
|
|
|
|
case 'list':
|
|
// Directly convert list items
|
|
blocks.push({
|
|
type: 'list',
|
|
data: {
|
|
style: item.style || 'unordered',
|
|
items: item.items || []
|
|
}
|
|
});
|
|
break;
|
|
|
|
case 'header':
|
|
// Header với level cụ thể (h2, h3, h4)
|
|
blocks.push({
|
|
type: 'header',
|
|
data: {
|
|
text: item.text,
|
|
level: item.level || 2
|
|
}
|
|
});
|
|
break;
|
|
|
|
case 'section':
|
|
blocks.push({
|
|
type: 'header',
|
|
data: {
|
|
text: item.title,
|
|
level: 2
|
|
}
|
|
});
|
|
|
|
if (item.content) {
|
|
// Split content by <br/> and create separate paragraphs
|
|
const contentParts = item.content.split(/<br\/?>/);
|
|
contentParts.forEach(part => {
|
|
if (part.trim()) {
|
|
blocks.push({
|
|
type: 'paragraph',
|
|
data: {
|
|
text: part.trim()
|
|
}
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
if (item.subsections) {
|
|
item.subsections.forEach(subsection => {
|
|
if (subsection.type === 'cancellation_table' || subsection.type === 'cancellation_section') {
|
|
blocks.push({
|
|
type: 'header',
|
|
data: {
|
|
text: subsection.title,
|
|
level: 3
|
|
}
|
|
});
|
|
|
|
if (subsection.items) {
|
|
blocks.push({
|
|
type: 'list',
|
|
data: {
|
|
style: 'unordered',
|
|
items: subsection.items
|
|
}
|
|
});
|
|
}
|
|
} else if (subsection.type === 'note') {
|
|
blocks.push({
|
|
type: 'quote',
|
|
data: {
|
|
text: subsection.text,
|
|
caption: '',
|
|
alignment: 'left'
|
|
}
|
|
});
|
|
}
|
|
});
|
|
}
|
|
break;
|
|
|
|
case 'note':
|
|
blocks.push({
|
|
type: 'quote',
|
|
data: {
|
|
text: item.text,
|
|
caption: '',
|
|
alignment: 'left'
|
|
}
|
|
});
|
|
break;
|
|
case 'embed':
|
|
blocks.push({
|
|
type: 'embed',
|
|
data: {
|
|
service: 'youtube',
|
|
source: item.url,
|
|
embed: item.videoId ? `https://www.youtube.com/embed/${item.videoId}` : item.url,
|
|
width: 580,
|
|
height: 320,
|
|
caption: item.caption || '',
|
|
title: item.title || ''
|
|
}
|
|
});
|
|
break;
|
|
case 'image':
|
|
blocks.push({
|
|
type: 'image',
|
|
data: {
|
|
file: {
|
|
url: item.url
|
|
},
|
|
caption: item.caption || '',
|
|
withBorder: false,
|
|
withBackground: false,
|
|
stretched: false
|
|
}
|
|
});
|
|
break;
|
|
}
|
|
});
|
|
|
|
return {
|
|
time: Date.now(),
|
|
blocks: blocks,
|
|
version: "2.28.2"
|
|
};
|
|
}
|
|
}
|
|
|
|
// Initialize Editor
|
|
document.addEventListener('DOMContentLoaded', async () => {
|
|
// Initialize content manager
|
|
window.termsContentManager = new TermsContentManager();
|
|
|
|
// Load existing content if available
|
|
let initialEditorData = {
|
|
time: Date.now(),
|
|
blocks: [],
|
|
version: "2.28.2"
|
|
};
|
|
|
|
if (window.TERMS_DATA?.content?.content) {
|
|
try {
|
|
const termsContent = window.TERMS_DATA.content.content;
|
|
initialEditorData = window.termsContentManager.convertTermsToEditor(termsContent);
|
|
} catch (error) {
|
|
console.error('Error loading terms content:', error);
|
|
}
|
|
}
|
|
|
|
// SmartList class với khả năng phát hiện dòng được chọn
|
|
class SmartList extends window.List {
|
|
constructor({data, config, api, readOnly}) {
|
|
super({data, config, api, readOnly});
|
|
this._element = null;
|
|
this.selectedText = ''; // Lưu text của dòng được chọn
|
|
this.selectedIndex = -1;
|
|
this.selectedFullText = '';
|
|
}
|
|
|
|
render() {
|
|
this._element = super.render();
|
|
|
|
// Thêm sự kiện để phát hiện dòng được chọn
|
|
this._element.addEventListener('mouseup', () => {
|
|
setTimeout(() => this.detectSelectedItem(), 100);
|
|
});
|
|
|
|
this._element.addEventListener('keyup', () => {
|
|
setTimeout(() => this.detectSelectedItem(), 100);
|
|
});
|
|
|
|
return this._element;
|
|
}
|
|
|
|
detectSelectedItem() {
|
|
const selection = window.getSelection();
|
|
if (!selection.rangeCount) return;
|
|
|
|
const range = selection.getRangeAt(0);
|
|
let listItem = range.commonAncestorContainer;
|
|
|
|
// Tìm phần tử li gần nhất
|
|
while (listItem && listItem.nodeName !== 'LI') {
|
|
listItem = listItem.parentNode;
|
|
}
|
|
|
|
if (listItem && this._element.contains(listItem)) {
|
|
// Lấy index của item được chọn
|
|
const items = this._element.querySelectorAll('li');
|
|
const index = Array.from(items).indexOf(listItem);
|
|
|
|
if (index >= 0) {
|
|
// Lưu text được chọn (có thể chỉ một phần)
|
|
this.selectedText = selection.toString().trim() || listItem.textContent.trim();
|
|
this.selectedIndex = index;
|
|
this.selectedFullText = listItem.textContent.trim();
|
|
|
|
// Đánh dấu visual
|
|
this.clearHighlights();
|
|
listItem.style.backgroundColor = 'rgba(0, 123, 255, 0.1)';
|
|
}
|
|
}
|
|
}
|
|
|
|
clearHighlights() {
|
|
if (!this._element) return;
|
|
this._element.querySelectorAll('li').forEach(li => {
|
|
li.style.backgroundColor = '';
|
|
});
|
|
}
|
|
|
|
save(blockContent) {
|
|
const savedData = super.save(blockContent);
|
|
|
|
// Lưu thông tin dòng được chọn
|
|
if (this.selectedIndex !== undefined && this.selectedIndex >= 0) {
|
|
savedData.selectedIndex = this.selectedIndex;
|
|
savedData.selectedText = this.selectedText;
|
|
savedData.selectedFullText = this.selectedFullText;
|
|
savedData.hasPartialSelection = this.selectedText !== this.selectedFullText;
|
|
}
|
|
|
|
return savedData;
|
|
}
|
|
|
|
static get conversionConfig() {
|
|
return {
|
|
export: (data) => {
|
|
// Export text đã chọn
|
|
return data.selectedText || data.items[0] || '';
|
|
},
|
|
import: (content) => {
|
|
return {
|
|
items: [content],
|
|
style: 'unordered'
|
|
};
|
|
}
|
|
};
|
|
}
|
|
}
|
|
|
|
// Helper method để convert toàn bộ item
|
|
function convertEntireListItem(editor, blockIndex, allItems, listStyle, itemIndex) {
|
|
const itemToConvert = allItems[itemIndex];
|
|
const remainingItems = allItems.filter((item, idx) => idx !== itemIndex);
|
|
|
|
// Xóa list gốc
|
|
editor.blocks.delete(blockIndex);
|
|
|
|
// Insert header
|
|
editor.blocks.insert('header', {
|
|
text: itemToConvert,
|
|
level: 3
|
|
}, {}, blockIndex, true);
|
|
|
|
// Insert remaining list nếu còn
|
|
if (remainingItems.length > 0) {
|
|
setTimeout(() => {
|
|
editor.blocks.insert('list', {
|
|
style: listStyle,
|
|
items: remainingItems
|
|
}, {}, blockIndex + 1, false);
|
|
}, 50);
|
|
}
|
|
|
|
return Promise.resolve();
|
|
}
|
|
|
|
// Initialize Editor
|
|
let blogEditorInstance = null;
|
|
try {
|
|
if (window.BlogEditor) {
|
|
const initialCustom = window.TERMS_DATA?.content?.content ? { blocks: window.TERMS_DATA.content.content } : { blocks: [] };
|
|
blogEditorInstance = new window.BlogEditor('editorjs', initialCustom);
|
|
window.blogEditorInstance = blogEditorInstance;
|
|
console.log('BlogEditor instance created');
|
|
} else {
|
|
console.warn('BlogEditor not available as global. Falling back to EditorJS init.');
|
|
|
|
// Try to dynamically import the Conclusion tool
|
|
let ConclusionClass = null;
|
|
try {
|
|
const mod = await import('/js/tools/conclusion.js');
|
|
ConclusionClass = mod.default || mod.Conclusion || null;
|
|
} catch (e) {
|
|
console.warn('Could not load Conclusion tool module:', e);
|
|
}
|
|
|
|
// image upload target for this editor
|
|
const uploadTarget = '/admin/upload/image?imageType=terms';
|
|
|
|
// build tools object and include Conclusion only when available
|
|
const toolsObj = {
|
|
header: {
|
|
class: window.Header,
|
|
config: {
|
|
placeholder: 'Enter section title',
|
|
levels: [2,3,4],
|
|
defaultLevel: 2
|
|
}
|
|
},
|
|
paragraph: {
|
|
class: window.Paragraph,
|
|
inlineToolbar: true
|
|
},
|
|
list: {
|
|
class: SmartList,
|
|
inlineToolbar: true,
|
|
config: {
|
|
defaultStyle: 'unordered'
|
|
}
|
|
},
|
|
image: {
|
|
class: window.ImageTool,
|
|
config: {
|
|
endpoints: { byFile: uploadTarget },
|
|
field: 'image',
|
|
types: 'image/*',
|
|
additionalRequestData: { imageType: 'terms' },
|
|
uploader: {
|
|
uploadByFile: async (file) => {
|
|
try {
|
|
const formData = new FormData();
|
|
formData.append('image', file);
|
|
const resp = await fetch(uploadTarget, {
|
|
method: 'POST',
|
|
body: formData
|
|
});
|
|
const result = await resp.json();
|
|
if (result && result.success) {
|
|
return {
|
|
success: 1,
|
|
file: {
|
|
url: result.path
|
|
}
|
|
};
|
|
}
|
|
return {
|
|
success: 0,
|
|
error: result?.error || 'Upload failed'
|
|
};
|
|
} catch (err) {
|
|
console.error('Image upload error:', err);
|
|
return {
|
|
success: 0,
|
|
error: 'Upload failed'
|
|
};
|
|
}
|
|
}
|
|
}
|
|
}
|
|
},
|
|
quote: {
|
|
class: window.Quote,
|
|
inlineToolbar: true,
|
|
config: {
|
|
quotePlaceholder: 'Enter a quote',
|
|
captionPlaceholder: "Quote's author"
|
|
}
|
|
},
|
|
marker: {
|
|
class: window.Marker,
|
|
shortcut: 'CMD+SHIFT+M'
|
|
},
|
|
embed: {
|
|
class: window.Embed,
|
|
config: {
|
|
services: { youtube: true },
|
|
inlineToolbar: true
|
|
}
|
|
}
|
|
};
|
|
|
|
if (ConclusionClass) {
|
|
toolsObj.conclusion = {
|
|
class: ConclusionClass,
|
|
inlineToolbar: true
|
|
};
|
|
}
|
|
|
|
try {
|
|
window.termsEditor = new window.EditorJS({
|
|
holder: 'editorjs',
|
|
placeholder: 'Write your Terms & Conditions content here...',
|
|
data: initialEditorData,
|
|
tools: toolsObj
|
|
});
|
|
|
|
// Override blocks.convert để xử lý tách list
|
|
window.termsEditor.isReady.then(() => {
|
|
console.log('Terms Editor ready with smart list conversion');
|
|
|
|
// Lưu reference đến convert gốc
|
|
const originalConvert = window.termsEditor.blocks.convert;
|
|
|
|
// Override convert method
|
|
window.termsEditor.blocks.convert = async function(blockIndex, targetType) {
|
|
try {
|
|
const block = await window.termsEditor.blocks.getBlockByIndex(blockIndex);
|
|
|
|
if (block && block.name === 'list' && (targetType === 'header' || targetType === 'heading')) {
|
|
console.log('=== List to Header Conversion (Single Line) ===');
|
|
|
|
// Lấy data đầy đủ của list
|
|
const savedData = await window.termsEditor.saver.save();
|
|
const listBlock = savedData.blocks[blockIndex];
|
|
|
|
if (!listBlock || !listBlock.data || !listBlock.data.items) {
|
|
console.error('Invalid list block data');
|
|
return originalConvert.call(this, blockIndex, targetType);
|
|
}
|
|
|
|
const allItems = [...listBlock.data.items];
|
|
const listStyle = listBlock.data.style || 'unordered';
|
|
|
|
// Kiểm tra xem có dòng được chọn không
|
|
const selectedIndex = listBlock.data.selectedIndex;
|
|
const selectedText = listBlock.data.selectedText || listBlock.data.selectedFullText;
|
|
|
|
if (selectedIndex === undefined || !selectedText) {
|
|
// Không có dòng nào được chọn, convert toàn bộ item đầu tiên
|
|
console.warn('No selected item, converting first item');
|
|
return convertEntireListItem(window.termsEditor, blockIndex, allItems, listStyle, 0);
|
|
}
|
|
|
|
console.log('Selected index:', selectedIndex);
|
|
console.log('Selected text:', selectedText);
|
|
console.log('All items:', allItems);
|
|
|
|
// Xóa list gốc
|
|
await window.termsEditor.blocks.delete(blockIndex);
|
|
|
|
// Đợi DOM update
|
|
await new Promise(resolve => setTimeout(resolve, 10));
|
|
|
|
// Tạo mảng items mới
|
|
const newItems = [];
|
|
let headerInserted = false;
|
|
let insertPosition = blockIndex;
|
|
|
|
// Duyệt qua tất cả items và xây dựng lại list
|
|
for (let i = 0; i < allItems.length; i++) {
|
|
if (i === selectedIndex) {
|
|
// Item được chọn -> chuyển thành header
|
|
console.log('Inserting header for item:', selectedText);
|
|
await window.termsEditor.blocks.insert('header', {
|
|
text: selectedText,
|
|
level: 3
|
|
}, {}, insertPosition, true);
|
|
|
|
headerInserted = true;
|
|
insertPosition++; // Tăng vị trí insert cho block tiếp theo
|
|
|
|
// Kiểm tra xem có phải chỉ chọn một phần text không
|
|
const hasPartialSelection = listBlock.data.hasPartialSelection;
|
|
const originalText = listBlock.data.selectedFullText || allItems[i];
|
|
|
|
if (hasPartialSelection && selectedText !== originalText) {
|
|
// Nếu chỉ chọn một phần, giữ lại phần còn lại trong list
|
|
const remainingText = originalText.replace(selectedText, '').trim();
|
|
if (remainingText) {
|
|
newItems.push(remainingText);
|
|
}
|
|
}
|
|
} else {
|
|
// Giữ nguyên các items khác
|
|
newItems.push(allItems[i]);
|
|
}
|
|
}
|
|
|
|
// Nếu vẫn còn items trong list, insert list mới
|
|
if (newItems.length > 0) {
|
|
console.log('Inserting remaining list with items:', newItems);
|
|
await new Promise(resolve => setTimeout(resolve, 50));
|
|
await window.termsEditor.blocks.insert('list', {
|
|
style: listStyle,
|
|
items: newItems
|
|
}, {}, insertPosition, false);
|
|
}
|
|
|
|
console.log('=== Conversion completed successfully ===');
|
|
return Promise.resolve(block);
|
|
}
|
|
} catch (error) {
|
|
console.error('!!! Error in convert override:', error);
|
|
}
|
|
|
|
// Gọi convert gốc cho các trường hợp khác
|
|
return originalConvert.call(this, blockIndex, targetType);
|
|
};
|
|
});
|
|
} catch (err) {
|
|
console.error('Fallback EditorJS init failed:', err);
|
|
window.termsEditor = new window.EditorJS({
|
|
holder: 'editorjs',
|
|
data: initialEditorData
|
|
});
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.error('Error initializing editor module:', err);
|
|
// attempt fallback
|
|
window.termsEditor = new window.EditorJS({
|
|
holder: 'editorjs',
|
|
data: initialEditorData
|
|
});
|
|
}
|
|
|
|
// Form submission handler
|
|
const form = document.getElementById('termsForm');
|
|
form.addEventListener('submit', async (e) => {
|
|
e.preventDefault();
|
|
|
|
const saveBtn = document.getElementById('saveBtn');
|
|
saveBtn.disabled = true;
|
|
saveBtn.innerHTML = '<i class="fas fa-spinner fa-spin me-2"></i>Saving...';
|
|
|
|
try {
|
|
// Save editor content (use BlogEditor instance if available to leverage uploader/autosave)
|
|
let editorData = null;
|
|
if (window.blogEditorInstance?.editor) {
|
|
editorData = await window.blogEditorInstance.editor.save();
|
|
} else if (window.termsEditor) {
|
|
editorData = await window.termsEditor.save();
|
|
} else {
|
|
throw new Error('Editor instance not available');
|
|
}
|
|
|
|
// Convert to Terms content structure
|
|
const termsContent = window.termsContentManager.convertEditorToTerms(editorData);
|
|
|
|
// Prepare JSON data
|
|
const heroData = {
|
|
title: document.getElementById('heroTitle').value.trim(),
|
|
backgroundImage: document.getElementById('heroBackgroundImage').value.trim(),
|
|
sectionClass: 'uk-section-default uk-section-overlap uk-preserve-color uk-light uk-position-relative',
|
|
backgroundClasses: 'uk-background-norepeat uk-background-cover uk-background-top-center uk-section uk-section-xlarge',
|
|
overlayStyle: {
|
|
backgroundColor: 'rgba(0, 0, 0, 0)'
|
|
},
|
|
titleClass: 'text-white text-[5vw] uk-text-center',
|
|
enableScrollspy: true
|
|
};
|
|
|
|
const pageData = {
|
|
title: document.getElementById('pageTitle').value.trim(),
|
|
divider: true,
|
|
sectionClass: 'uk-section-default uk-section-overlap uk-section',
|
|
titleClass: 'text-[2.5vw] text-[#292c3d] uk-text-left@m uk-text-center',
|
|
dividerClass: 'uk-divider-small uk-text-left@m uk-text-center'
|
|
};
|
|
|
|
const contentData = {
|
|
sectionClass: 'uk-section-muted uk-section-overlap uk-section',
|
|
textClass: 'uk-panel uk-margin text-[1vw]',
|
|
content: termsContent
|
|
};
|
|
|
|
// Set hidden inputs
|
|
document.getElementById('heroJson').value = JSON.stringify(heroData);
|
|
document.getElementById('pageJson').value = JSON.stringify(pageData);
|
|
document.getElementById('contentJson').value = JSON.stringify(contentData);
|
|
|
|
// Submit form
|
|
form.submit();
|
|
} catch (error) {
|
|
console.error('Error saving terms content:', error);
|
|
alert('Error saving content. Please check console for details.');
|
|
saveBtn.disabled = false;
|
|
saveBtn.innerHTML = '<i class="fas fa-save me-2"></i>Save Changes';
|
|
}
|
|
});
|
|
|
|
// Preview functionality
|
|
const previewBtn = document.querySelector('.preview-btn');
|
|
const previewModal = new bootstrap.Modal(document.getElementById('previewModal'));
|
|
|
|
previewBtn.addEventListener('click', async function () {
|
|
try {
|
|
// Save editor content first
|
|
let editorData = null;
|
|
if (window.blogEditorInstance?.editor) {
|
|
editorData = await window.blogEditorInstance.editor.save();
|
|
} else if (window.termsEditor) {
|
|
editorData = await window.termsEditor.save();
|
|
} else {
|
|
throw new Error('Editor instance not available');
|
|
}
|
|
const termsContent = window.termsContentManager.convertEditorToTerms(editorData);
|
|
|
|
// Prepare data for preview
|
|
const formData = new FormData();
|
|
formData.append('content', JSON.stringify(termsContent));
|
|
formData.append('heroTitle', document.getElementById('heroTitle').value);
|
|
formData.append('heroBackgroundImage', document.getElementById('heroBackgroundImage').value);
|
|
formData.append('pageTitle', document.getElementById('pageTitle').value);
|
|
|
|
const response = await fetch('/admin/terms/preview', {
|
|
method: 'POST',
|
|
body: formData
|
|
});
|
|
|
|
if (!response.ok) throw new Error('Preview failed');
|
|
|
|
const html = await response.text();
|
|
const previewFrame = document.getElementById('previewFrame');
|
|
const blob = new Blob([html], { type: 'text/html' });
|
|
previewFrame.src = URL.createObjectURL(blob);
|
|
previewModal.show();
|
|
} catch (error) {
|
|
console.error('Preview error:', error);
|
|
alert('Error generating preview. Please check console for details.');
|
|
}
|
|
});
|
|
|
|
// Image upload handling
|
|
document.querySelectorAll('.btn-upload-image').forEach(btn => {
|
|
btn.addEventListener('click', function () {
|
|
const targetInput = this.getAttribute('data-target-input');
|
|
const imageType = this.getAttribute('data-image-type');
|
|
document.getElementById('currentImageType').value = imageType;
|
|
document.getElementById('currentTargetInput').value = targetInput;
|
|
document.getElementById('directImageUpload').click();
|
|
});
|
|
});
|
|
|
|
// Handle file selection
|
|
document.getElementById('directImageUpload').addEventListener('change', async function (e) {
|
|
if (!this.files || !this.files[0]) return;
|
|
|
|
const formData = new FormData();
|
|
formData.append('image', this.files[0]);
|
|
const imageType = document.getElementById('currentImageType').value;
|
|
const targetInput = document.getElementById('currentTargetInput').value;
|
|
|
|
try {
|
|
const uploadResponse = await fetch(`/admin/upload/image?imageType=${imageType}`, {
|
|
method: 'POST',
|
|
body: formData
|
|
});
|
|
|
|
const uploadResult = await uploadResponse.json();
|
|
|
|
if (uploadResult.success && uploadResult.path) {
|
|
const inputElement = document.getElementById(targetInput);
|
|
if (inputElement) {
|
|
inputElement.value = uploadResult.path;
|
|
|
|
// Update preview
|
|
if (targetInput === 'heroBackgroundImage') {
|
|
updateHeroImagePreview(uploadResult.path);
|
|
}
|
|
|
|
// Show success message
|
|
showToast('Image uploaded successfully', 'success');
|
|
}
|
|
} else {
|
|
showToast(uploadResult.error || 'Error uploading image', 'error');
|
|
}
|
|
} catch (error) {
|
|
console.error('Upload error:', error);
|
|
showToast('Error uploading image', 'error');
|
|
}
|
|
|
|
this.value = '';
|
|
});
|
|
|
|
function updateHeroImagePreview(imageUrl) {
|
|
const previewDiv = document.getElementById('heroImagePreview');
|
|
let img = previewDiv.querySelector('img');
|
|
let fallback = previewDiv.querySelector('.border');
|
|
|
|
if (!img) {
|
|
img = document.createElement('img');
|
|
img.className = 'img-thumbnail';
|
|
img.style.height = '200px';
|
|
img.style.width = '100%';
|
|
img.style.objectFit = 'cover';
|
|
img.alt = 'Background image preview';
|
|
img.onerror = function() {
|
|
this.style.display = 'none';
|
|
if (fallback) fallback.style.display = 'flex';
|
|
};
|
|
previewDiv.insertBefore(img, fallback);
|
|
}
|
|
|
|
img.src = imageUrl;
|
|
img.style.display = 'block';
|
|
if (fallback) fallback.style.display = 'none';
|
|
}
|
|
|
|
function showToast(message, type = 'info') {
|
|
const toast = document.createElement('div');
|
|
toast.className = `toast align-items-center text-bg-${type} border-0 position-fixed bottom-0 end-0 m-3`;
|
|
toast.setAttribute('role', 'alert');
|
|
toast.innerHTML = `
|
|
<div class="d-flex">
|
|
<div class="toast-body">${message}</div>
|
|
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
|
|
</div>
|
|
`;
|
|
document.body.appendChild(toast);
|
|
|
|
const bsToast = new bootstrap.Toast(toast, { delay: 3000 });
|
|
bsToast.show();
|
|
|
|
toast.addEventListener('hidden.bs.toast', () => {
|
|
toast.remove();
|
|
});
|
|
}
|
|
|
|
// Custom handler để tách list item thành header với Ctrl+Shift+H
|
|
window.addEventListener('keydown', async (e) => {
|
|
// Ctrl/Cmd + Shift + H = Convert list item sang header
|
|
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 'H') {
|
|
e.preventDefault();
|
|
|
|
const editor = window.blogEditorInstance?.editor || window.termsEditor;
|
|
if (!editor) return;
|
|
|
|
try {
|
|
const currentBlockIndex = editor.blocks.getCurrentBlockIndex();
|
|
const block = await editor.blocks.getBlockByIndex(currentBlockIndex);
|
|
|
|
if (block && block.name === 'list') {
|
|
// Trigger conversion to header
|
|
await editor.blocks.convert(currentBlockIndex, 'header');
|
|
|
|
showToast('Converted selected list item to header', 'success');
|
|
} else {
|
|
showToast('Please select text in a list item first', 'info');
|
|
}
|
|
} catch (error) {
|
|
console.error('Error converting list item:', error);
|
|
showToast('Error converting to header', 'error');
|
|
}
|
|
}
|
|
});
|
|
|
|
// Thêm CSS để hiển thị dòng được chọn trong list
|
|
const style = document.createElement('style');
|
|
style.textContent = `
|
|
.ce-list__item--selected {
|
|
background-color: rgba(0, 123, 255, 0.1) !important;
|
|
border-left: 3px solid #007bff;
|
|
padding-left: 8px !important;
|
|
}
|
|
|
|
.ce-list__item--partial-selection {
|
|
background-color: rgba(255, 193, 7, 0.1) !important;
|
|
border-left: 3px solid #ffc107;
|
|
}
|
|
|
|
/* Tooltip for list items */
|
|
.ce-block--selected .ce-list {
|
|
position: relative;
|
|
}
|
|
|
|
.ce-block--selected .ce-list::after {
|
|
content: 'Select text and press Ctrl+Shift+H to convert to header';
|
|
position: absolute;
|
|
top: -30px;
|
|
left: 0;
|
|
background: #333;
|
|
color: white;
|
|
padding: 4px 8px;
|
|
border-radius: 4px;
|
|
font-size: 12px;
|
|
white-space: nowrap;
|
|
z-index: 1000;
|
|
display: none;
|
|
}
|
|
|
|
.ce-block--selected .ce-list:hover::after {
|
|
display: block;
|
|
}
|
|
`;
|
|
document.head.appendChild(style);
|
|
});
|
|
|
|
// Store terms data in global variable
|
|
window.TERMS_DATA = <%- JSON.stringify(data) %>;
|
|
</script> |