forked from UKSOURCE/cms.hailearning.edu.vn
first commit
This commit is contained in:
353
views/admin/travel/index.ejs
Normal file
353
views/admin/travel/index.ejs
Normal file
@@ -0,0 +1,353 @@
|
||||
<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);">
|
||||
Travel Information Editor
|
||||
</h1>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button type="button" class="btn btn-outline-primary preview-btn">
|
||||
<i class="fas fa-eye me-2"></i>Preview
|
||||
</button>
|
||||
<button type="submit" form="travelForm" class="btn btn-primary" id="saveBtn">
|
||||
<i class="fas fa-save me-2"></i>Save Changes
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form id="travelForm" action="/admin/travel/update" method="POST" class="needs-validation" novalidate>
|
||||
<input type="hidden" name="hero" id="heroJson">
|
||||
<input type="hidden" name="page" id="pageJson">
|
||||
<input type="hidden" name="content" id="contentJson">
|
||||
<input type="hidden" name="enableScrollspy" id="enableScrollspyInput">
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-8">
|
||||
<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 || '' %>" readonly>
|
||||
<button type="button" class="btn btn-outline-primary btn-upload-image"
|
||||
data-target-input="heroBackgroundImage" data-image-type="travel">
|
||||
<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) { %>
|
||||
<img src="<%= data.hero.backgroundImage %>" class="img-thumbnail"
|
||||
style="height: 200px; width: 100%; object-fit: cover;" alt="Background image preview">
|
||||
<% } else { %>
|
||||
<div class="border rounded d-flex align-items-center justify-content-center h-100 bg-light">
|
||||
<span class="text-muted">No image selected</span>
|
||||
</div>
|
||||
<% } %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mt-3">
|
||||
<div class="col-md-12">
|
||||
<label class="form-label">Hero Title</label>
|
||||
<textarea class="form-control" id="heroTitle" rows="2"><%= data.hero?.title || 'Travel Information' %></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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</label>
|
||||
<input type="text" class="form-control" id="pageTitle" value="<%= data.page?.title || 'Go and Grow Camp Travel Information' %>">
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label">As of / Year</label>
|
||||
<input type="text" class="form-control" id="pageYear" value="<%= data.page?.year || '' %>">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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 content using the blog editor</p>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="editorjs" class="border rounded p-3" style="min-height: 500px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-4">
|
||||
<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 || '' %>">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Meta Description</label>
|
||||
<textarea class="form-control" id="metadataDescription" rows="3"><%= data.page?.metadata?.description || '' %></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" id="enableScrollspy" <%= data.enableScrollspy ? 'checked' : '' %>>
|
||||
<label class="form-check-label" for="enableScrollspy">Enable Scrollspy Navigation</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
<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">Travel Information 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>
|
||||
|
||||
<input type="file" id="directImageUpload" style="display: none;" accept="image/*">
|
||||
<input type="hidden" id="currentImageType">
|
||||
<input type="hidden" id="currentTargetInput">
|
||||
|
||||
<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 type="module">
|
||||
import BlogEditor from '/js/editor.js';
|
||||
|
||||
// Logic xử lý lọc dữ liệu để tránh duplicate video và xóa paragraph rỗng
|
||||
class TravelContentManager {
|
||||
cleanEditorData(editorData) {
|
||||
const cleanedBlocks = [];
|
||||
const seenVideoIds = new Set();
|
||||
const youtubeRegex = /(?:https?:\/\/)?(?:www\.)?(?:youtube\.com|youtu\.be)\/(?:watch\?v=|embed\/|v\/|shorts\/)?([A-Za-z0-9_-]{11})/;
|
||||
|
||||
(editorData.blocks || []).forEach(block => {
|
||||
if (!block) return;
|
||||
|
||||
// 1. Xử lý Video Embed (Deduplication)
|
||||
if (block.type === 'embed') {
|
||||
const bd = block.data || {};
|
||||
const source = bd.source || bd.embed || '';
|
||||
const match = source.match(youtubeRegex);
|
||||
const vid = bd.videoId || (match ? match[1] : null);
|
||||
|
||||
if (vid) {
|
||||
if (seenVideoIds.has(vid)) return; // Bỏ qua nếu đã có video này
|
||||
seenVideoIds.add(vid);
|
||||
}
|
||||
cleanedBlocks.push(block);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Xử lý Paragraph (Xóa dòng trống hoặc dòng chỉ chứa link đã embed)
|
||||
if (block.type === 'paragraph') {
|
||||
const text = (block.data?.text || '').toString().trim();
|
||||
if (text === '') return; // Xóa paragraph rỗng
|
||||
|
||||
const match = text.match(youtubeRegex);
|
||||
if (match && match[1]) {
|
||||
// Nếu paragraph chỉ chứa link YouTube, và ta sẽ có/đã có block embed cho nó, thì bỏ qua paragraph
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
cleanedBlocks.push(block);
|
||||
});
|
||||
|
||||
return { ...editorData, blocks: cleanedBlocks };
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
let blogEditorInstance = null;
|
||||
const travelData = <%- JSON.stringify(data) %>;
|
||||
const initialContent = travelData?.content || { blocks: [] };
|
||||
|
||||
try {
|
||||
blogEditorInstance = new BlogEditor('editorjs', initialContent, 'travel');
|
||||
window.blogEditorInstance = blogEditorInstance;
|
||||
} catch (error) {
|
||||
console.error('Error initializing BlogEditor:', error);
|
||||
}
|
||||
|
||||
const form = document.getElementById('travelForm');
|
||||
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 {
|
||||
// Lấy dữ liệu thô
|
||||
const rawData = await blogEditorInstance.save();
|
||||
|
||||
// Làm sạch dữ liệu trước khi đóng gói JSON
|
||||
const travelManager = new TravelContentManager();
|
||||
const cleanedData = travelManager.cleanEditorData(rawData);
|
||||
|
||||
const heroData = {
|
||||
title: document.getElementById('heroTitle').value.trim(),
|
||||
backgroundImage: document.getElementById('heroBackgroundImage').value.trim(),
|
||||
};
|
||||
|
||||
const pageData = {
|
||||
title: document.getElementById('pageTitle').value.trim(),
|
||||
year: document.getElementById('pageYear')?.value.trim(),
|
||||
metadata: {
|
||||
title: document.getElementById('metadataTitle').value.trim(),
|
||||
description: document.getElementById('metadataDescription').value.trim(),
|
||||
},
|
||||
};
|
||||
|
||||
document.getElementById('heroJson').value = JSON.stringify(heroData);
|
||||
document.getElementById('pageJson').value = JSON.stringify(pageData);
|
||||
document.getElementById('contentJson').value = JSON.stringify(cleanedData);
|
||||
document.getElementById('enableScrollspyInput').value = document.getElementById('enableScrollspy').checked;
|
||||
|
||||
form.submit();
|
||||
} catch (error) {
|
||||
console.error('Save error:', error);
|
||||
saveBtn.disabled = false;
|
||||
saveBtn.innerHTML = '<i class="fas fa-save me-2"></i>Save Changes';
|
||||
}
|
||||
});
|
||||
|
||||
// Preview
|
||||
const previewBtn = document.querySelector('.preview-btn');
|
||||
const previewModal = new bootstrap.Modal(document.getElementById('previewModal'));
|
||||
previewBtn.addEventListener('click', async function () {
|
||||
try {
|
||||
const editorData = await blogEditorInstance.save();
|
||||
const travelManager = new TravelContentManager();
|
||||
const cleanedData = travelManager.cleanEditorData(editorData);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('content', JSON.stringify(cleanedData));
|
||||
formData.append('heroTitle', document.getElementById('heroTitle').value);
|
||||
formData.append('heroBackgroundImage', document.getElementById('heroBackgroundImage').value);
|
||||
formData.append('pageTitle', document.getElementById('pageTitle').value);
|
||||
formData.append('pageYear', document.getElementById('pageYear')?.value || '');
|
||||
|
||||
const response = await fetch('/admin/travel/preview', { method: 'POST', body: formData });
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
// Image Upload Helpers
|
||||
document.querySelectorAll('.btn-upload-image').forEach(btn => {
|
||||
btn.addEventListener('click', function () {
|
||||
document.getElementById('currentImageType').value = this.getAttribute('data-image-type');
|
||||
document.getElementById('currentTargetInput').value = this.getAttribute('data-target-input');
|
||||
document.getElementById('directImageUpload').click();
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById('directImageUpload').addEventListener('change', async function () {
|
||||
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 resp = await fetch(`/admin/upload/image?imageType=${imageType}`, { method: 'POST', body: formData });
|
||||
const result = await resp.json();
|
||||
if (result.success) {
|
||||
document.getElementById(targetInput).value = result.path;
|
||||
if (targetInput === 'heroBackgroundImage') updateHeroImagePreview(result.path);
|
||||
showToast('Uploaded successfully', 'success');
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('Upload failed', 'danger');
|
||||
}
|
||||
});
|
||||
|
||||
function updateHeroImagePreview(imageUrl) {
|
||||
document.getElementById('heroImagePreview').innerHTML = `<img src="${imageUrl}" class="img-thumbnail" style="height: 200px; width: 100%; object-fit: cover;">`;
|
||||
}
|
||||
|
||||
function showToast(message, type) {
|
||||
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.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);
|
||||
new bootstrap.Toast(toast, { delay: 3000 }).show();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
Reference in New Issue
Block a user