forked from UKSOURCE/cms.hailearning.edu.vn
Add CMS support for floating contact widget with Facebook/Zalo quick actions. Includes mongoose schema, admin UI tab, image upload with sharp resize presets, deferred form submission with draft persistence, and upload middleware error handling.
406 lines
15 KiB
Plaintext
406 lines
15 KiB
Plaintext
<div class="container">
|
|
<div class="d-flex justify-content-between align-items-center mt-4 mb-4">
|
|
<div>
|
|
<h1 class="h3 mb-0" style="color: var(--primary-dark)">
|
|
Homepage Management
|
|
</h1>
|
|
<p class="text-muted mb-0">Edit content displayed on homepage</p>
|
|
</div>
|
|
<div>
|
|
<a href="<%= frontendUrl %>" class="btn btn-outline-primary" target="_blank">
|
|
<i class="fas fa-external-link-alt me-2"></i>View Homepage
|
|
</a>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="row">
|
|
<div class="col-12">
|
|
<form action="/admin/home/update" method="POST" class="content-with-fixed-buttons">
|
|
<!-- Hidden inputs for JSON data -->
|
|
<input type="hidden" name="hero" id="heroJson" />
|
|
<input type="hidden" name="whyChooseUs" id="whyChooseUsJson" />
|
|
<input type="hidden" name="visaSolutions" id="visaSolutionsJson" />
|
|
<input type="hidden" name="visaCountries" id="visaCountriesJson" />
|
|
<input type="hidden" name="testimonials" id="testimonialsJson" />
|
|
<input type="hidden" name="videoGallery" id="videoGalleryJson" />
|
|
<input type="hidden" name="faq" id="faqJson" />
|
|
<input type="hidden" name="achievements" id="achievementsJson" />
|
|
<input type="hidden" name="partners" id="partnersJson" />
|
|
<input type="hidden" name="blogPreview" id="blogPreviewJson" />
|
|
<input type="hidden" name="floatingContact" id="floatingContactJson" />
|
|
|
|
<!-- Navigation Tabs -->
|
|
<div class="card shadow-sm border-0 mb-4">
|
|
<div class="card-header bg-white border-bottom">
|
|
<ul class="nav nav-tabs card-header-tabs" role="tablist">
|
|
<li class="nav-item">
|
|
<a class="nav-link active" data-bs-toggle="tab" href="#hero" role="tab">
|
|
<i class="fas fa-home me-2"></i>Hero
|
|
</a>
|
|
</li>
|
|
<li class="nav-item">
|
|
<a class="nav-link" data-bs-toggle="tab" href="#whychooseus" role="tab">
|
|
<i class="fas fa-star me-2"></i>Why Choose Us
|
|
</a>
|
|
</li>
|
|
<li class="nav-item">
|
|
<a class="nav-link" data-bs-toggle="tab" href="#visasolutions" role="tab">
|
|
<i class="fas fa-concierge-bell me-2"></i>Visa Solutions
|
|
</a>
|
|
</li>
|
|
<li class="nav-item">
|
|
<a class="nav-link" data-bs-toggle="tab" href="#visacountries" role="tab">
|
|
<i class="fas fa-globe-americas me-2"></i>Visa Countries
|
|
</a>
|
|
</li>
|
|
<li class="nav-item">
|
|
<a class="nav-link" data-bs-toggle="tab" href="#testimonials" role="tab">
|
|
<i class="fas fa-comments me-2"></i>Testimonials
|
|
</a>
|
|
</li>
|
|
<li class="nav-item">
|
|
<a class="nav-link" data-bs-toggle="tab" href="#videogallery" role="tab">
|
|
<i class="fas fa-video me-2"></i>Video Gallery
|
|
</a>
|
|
</li>
|
|
<li class="nav-item">
|
|
<a class="nav-link" data-bs-toggle="tab" href="#faq" role="tab">
|
|
<i class="fas fa-question-circle me-2"></i>FAQ
|
|
</a>
|
|
</li>
|
|
<li class="nav-item">
|
|
<a class="nav-link" data-bs-toggle="tab" href="#achievements" role="tab">
|
|
<i class="fas fa-chart-pie me-2"></i>Achievements
|
|
</a>
|
|
</li>
|
|
<li class="nav-item">
|
|
<a class="nav-link" data-bs-toggle="tab" href="#partners" role="tab">
|
|
<i class="fas fa-handshake me-2"></i>Partners
|
|
</a>
|
|
</li>
|
|
<li class="nav-item">
|
|
<a class="nav-link" data-bs-toggle="tab" href="#blogpreview" role="tab">
|
|
<i class="fas fa-blog me-2"></i>Blog Preview
|
|
</a>
|
|
</li>
|
|
<li class="nav-item">
|
|
<a class="nav-link" data-bs-toggle="tab" href="#floatingcontact" role="tab">
|
|
<i class="fas fa-comment-dots me-2"></i>Floating Contact
|
|
</a>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
|
|
<div class="card-body">
|
|
<div class="tab-content">
|
|
<%- include('sections/hero') %>
|
|
<%- include('sections/whyChooseUs') %>
|
|
<%- include('sections/visaSolutions') %>
|
|
<%- include('sections/visaCountries') %>
|
|
<%- include('sections/testimonials') %>
|
|
<%- include('sections/videoGallery') %>
|
|
<%- include('sections/faq') %>
|
|
<%- include('sections/achievements') %>
|
|
<%- include('sections/partners') %>
|
|
<%- include('sections/blogPreview') %>
|
|
<%- include('sections/floatingContact') %>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Move buttons to fixed bottom -->
|
|
<div class="fixed-bottom-buttons">
|
|
<button type="reset" class="btn btn-secondary">
|
|
<i class="fas fa-undo"></i>
|
|
<span>Reset</span>
|
|
</button>
|
|
<button type="submit" class="btn btn-primary">
|
|
<i class="fas fa-save"></i>
|
|
<span>Save Changes</span>
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Image upload input -->
|
|
<input
|
|
type="file"
|
|
id="directImageUpload"
|
|
style="display: none"
|
|
accept="image/*,.png,.jpg,.jpeg,.gif,.webp,.svg" />
|
|
<input type="hidden" id="currentImageType" name="imageType" />
|
|
<input type="hidden" id="currentTargetInput" name="targetInput" />
|
|
<input type="hidden" id="currentResizePreset" name="resizePreset" />
|
|
|
|
<script>
|
|
/**
|
|
* BRIDGE SCRIPT: Cho phép các section lẻ tự đăng ký logic lấy dữ liệu.
|
|
* Cách dùng trong file lẻ (vị dụ hero.ejs):
|
|
* <script>
|
|
* window.homeScrapers = window.homeScrapers || {};
|
|
* window.homeScrapers.hero = () => ({ title: document.getElementById('heroTitle').value, ... });
|
|
* <\/script>
|
|
*/
|
|
window.homeScrapers = window.homeScrapers || {};
|
|
const pendingImageUploads = new Map();
|
|
const pendingPreviewUrls = new Map();
|
|
|
|
document.addEventListener("DOMContentLoaded", function () {
|
|
const form = document.querySelector("form");
|
|
if (form) {
|
|
form.addEventListener("submit", async function (e) {
|
|
if (form.dataset.submitting === "true") {
|
|
return;
|
|
}
|
|
|
|
e.preventDefault();
|
|
if (typeof form.reportValidity === "function" && !form.reportValidity()) {
|
|
return;
|
|
}
|
|
console.log("Form submitting, collecting data from scrapers...");
|
|
|
|
try {
|
|
await flushPendingImageUploads();
|
|
|
|
// Tự động thu gom dữ liệu từ các section đã đăng ký
|
|
Object.keys(window.homeScrapers).forEach(section => {
|
|
const input = document.getElementById(section + 'Json');
|
|
if (input) {
|
|
try {
|
|
const data = window.homeScrapers[section]();
|
|
console.log(`- Collected data for [${section}]:`, data);
|
|
input.value = JSON.stringify(data);
|
|
} catch (err) {
|
|
console.error(`Error scraping section [${section}]:`, err);
|
|
}
|
|
}
|
|
});
|
|
|
|
form.dataset.submitting = "true";
|
|
form.submit();
|
|
} catch (error) {
|
|
console.error("Error during deferred image uploads:", error);
|
|
showToast("Error", error.message || "Failed to upload pending images", "error");
|
|
}
|
|
});
|
|
}
|
|
|
|
// Khởi tạo các nút upload ảnh (dùng chung cho toàn bộ các section)
|
|
initImageUploads();
|
|
});
|
|
|
|
// --- UTILITIES (Dùng chung) ---
|
|
|
|
function extractHtmlErrorMessage(html) {
|
|
if (!html) {
|
|
return "";
|
|
}
|
|
|
|
const preMatch = html.match(/<pre>([\s\S]*?)<\/pre>/i);
|
|
const titleMatch = html.match(/<title>([\s\S]*?)<\/title>/i);
|
|
const rawMessage = (preMatch && preMatch[1]) || (titleMatch && titleMatch[1]) || html;
|
|
const decoded = rawMessage
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, "'")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/&/g, "&");
|
|
|
|
return decoded.replace(/\s+/g, " ").trim();
|
|
}
|
|
|
|
function findImagePreview(input) {
|
|
if (!input) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
input.closest(".input-group")?.nextElementSibling?.querySelector("img") ||
|
|
input.parentElement?.nextElementSibling?.querySelector("img") ||
|
|
null
|
|
);
|
|
}
|
|
|
|
function revokePendingPreview(targetInput) {
|
|
const previewUrl = pendingPreviewUrls.get(targetInput);
|
|
if (previewUrl) {
|
|
URL.revokeObjectURL(previewUrl);
|
|
pendingPreviewUrls.delete(targetInput);
|
|
}
|
|
}
|
|
|
|
function isFloatingContactTargetInput(targetInput) {
|
|
return typeof targetInput === "string" && targetInput.startsWith("floatingContact");
|
|
}
|
|
|
|
async function persistFloatingContactDraft() {
|
|
const scraper = window.homeScrapers && window.homeScrapers.floatingContact;
|
|
if (typeof scraper !== "function") {
|
|
throw new Error("Floating contact scraper is not available");
|
|
}
|
|
|
|
const response = await fetch("/admin/home/floating-contact/update", {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
Accept: "application/json",
|
|
},
|
|
body: JSON.stringify({
|
|
floatingContact: scraper(),
|
|
}),
|
|
});
|
|
const result = await response.json().catch(() => null);
|
|
|
|
if (!response.ok || !result?.success) {
|
|
throw new Error(result?.error || "Failed to save floating contact changes");
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
async function uploadPendingImage(targetInput, uploadConfig) {
|
|
const input = document.getElementById(targetInput);
|
|
if (!input || !uploadConfig?.file) {
|
|
pendingImageUploads.delete(targetInput);
|
|
revokePendingPreview(targetInput);
|
|
return;
|
|
}
|
|
|
|
const formData = new FormData();
|
|
formData.append("image", uploadConfig.file);
|
|
|
|
const query = new URLSearchParams({ imageType: uploadConfig.imageType });
|
|
if (uploadConfig.resizePreset) {
|
|
query.set("resizePreset", uploadConfig.resizePreset);
|
|
}
|
|
|
|
const response = await fetch(`/admin/upload/image?${query.toString()}`, {
|
|
method: "POST",
|
|
body: formData,
|
|
});
|
|
const rawResponse = await response.text();
|
|
|
|
let result = null;
|
|
try {
|
|
result = rawResponse ? JSON.parse(rawResponse) : null;
|
|
} catch (parseError) {
|
|
result = null;
|
|
}
|
|
|
|
if (!response.ok) {
|
|
throw new Error(
|
|
result?.error ||
|
|
extractHtmlErrorMessage(rawResponse) ||
|
|
`Upload failed with status ${response.status}`,
|
|
);
|
|
}
|
|
|
|
if (!result?.success || !result.path) {
|
|
throw new Error(result?.error || "Upload failed");
|
|
}
|
|
|
|
input.value = result.path;
|
|
input.dispatchEvent(new Event("input", { bubbles: true }));
|
|
input.dispatchEvent(new Event("change", { bubbles: true }));
|
|
|
|
const previewImg = findImagePreview(input);
|
|
if (previewImg) {
|
|
previewImg.src = new URL(result.path, window.location.origin).toString();
|
|
previewImg.classList.remove("d-none");
|
|
}
|
|
|
|
pendingImageUploads.delete(targetInput);
|
|
revokePendingPreview(targetInput);
|
|
}
|
|
|
|
async function flushPendingImageUploads() {
|
|
for (const [targetInput, uploadConfig] of pendingImageUploads.entries()) {
|
|
await uploadPendingImage(targetInput, uploadConfig);
|
|
}
|
|
}
|
|
|
|
function initImageUploads() {
|
|
document.addEventListener("click", function (e) {
|
|
const btn = e.target.closest(".btn-upload-image");
|
|
if (btn) {
|
|
document.getElementById("currentImageType").value = btn.dataset.imageType;
|
|
document.getElementById("currentTargetInput").value = btn.dataset.targetInput;
|
|
document.getElementById("currentResizePreset").value = btn.dataset.resizePreset || "";
|
|
document.getElementById("directImageUpload").click();
|
|
}
|
|
});
|
|
|
|
const fileInput = document.getElementById("directImageUpload");
|
|
if (fileInput) {
|
|
fileInput.addEventListener("change", handleDirectImageUpload);
|
|
}
|
|
}
|
|
|
|
async function handleDirectImageUpload(e) {
|
|
if (!this.files || !this.files[0]) return;
|
|
const file = this.files[0];
|
|
const imageType = document.getElementById("currentImageType").value;
|
|
const targetInput = document.getElementById("currentTargetInput").value;
|
|
const resizePreset = document.getElementById("currentResizePreset").value;
|
|
const allowedExtensions = /\.(jpe?g|png|gif|webp|svg)$/i;
|
|
|
|
if (!(file.type.startsWith("image/") || allowedExtensions.test(file.name))) {
|
|
showToast("Error", "Only image files are allowed", "error");
|
|
this.value = "";
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const input = document.getElementById(targetInput);
|
|
if (!input) {
|
|
throw new Error("Target image field not found");
|
|
}
|
|
|
|
revokePendingPreview(targetInput);
|
|
const previewUrl = URL.createObjectURL(file);
|
|
pendingPreviewUrls.set(targetInput, previewUrl);
|
|
|
|
const previewImg = findImagePreview(input);
|
|
if (previewImg) {
|
|
previewImg.src = previewUrl;
|
|
previewImg.classList.remove("d-none");
|
|
}
|
|
|
|
if (isFloatingContactTargetInput(targetInput)) {
|
|
pendingImageUploads.delete(targetInput);
|
|
await uploadPendingImage(targetInput, { file, imageType, resizePreset });
|
|
await persistFloatingContactDraft();
|
|
showToast("Success", "Image uploaded and saved immediately.", "success");
|
|
this.value = "";
|
|
return;
|
|
}
|
|
|
|
pendingImageUploads.set(targetInput, { file, imageType, resizePreset });
|
|
|
|
showToast("Ready", "Image selected. It will be uploaded when you save changes.", "info");
|
|
} catch (error) {
|
|
showToast("Error", "Upload failed: " + error.message, "error");
|
|
}
|
|
this.value = "";
|
|
}
|
|
|
|
function showToast(title, message, type = "info") {
|
|
let container = document.querySelector(".toast-container") || (() => {
|
|
const c = document.createElement("div");
|
|
c.className = "toast-container position-fixed top-0 end-0 p-3";
|
|
document.body.appendChild(c);
|
|
return c;
|
|
})();
|
|
|
|
const toast = document.createElement("div");
|
|
toast.className = `toast align-items-center text-white bg-${type === "error" ? "danger" : type} border-0`;
|
|
toast.setAttribute("role", "alert");
|
|
toast.innerHTML = `<div class="d-flex"><div class="toast-body"><strong>${title}:</strong> ${message}</div><button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button></div>`;
|
|
container.appendChild(toast);
|
|
new bootstrap.Toast(toast, { autohide: true, delay: 3000 }).show();
|
|
toast.addEventListener("hidden.bs.toast", () => toast.remove());
|
|
}
|
|
</script>
|