forked from UKSOURCE/cms.hailearning.edu.vn
538 lines
19 KiB
Plaintext
538 lines
19 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();
|
|
initHomeCharacterCounters(document);
|
|
initImagePreviewFallbacks(document);
|
|
});
|
|
|
|
// --- 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 clearImagePreviewError(previewImg) {
|
|
if (!previewImg?.parentElement) {
|
|
return;
|
|
}
|
|
|
|
previewImg.parentElement.querySelectorAll(".image-preview-missing").forEach((node) => node.remove());
|
|
}
|
|
|
|
function bindImagePreviewFallback(previewImg) {
|
|
if (!previewImg || previewImg.dataset.previewFallbackBound === "true") {
|
|
return;
|
|
}
|
|
|
|
previewImg.dataset.previewFallbackBound = "true";
|
|
previewImg.addEventListener("error", () => {
|
|
previewImg.classList.add("d-none");
|
|
previewImg.removeAttribute("src");
|
|
|
|
if (!previewImg.parentElement?.querySelector(".image-preview-missing")) {
|
|
const note = document.createElement("small");
|
|
note.className = "text-warning d-block mt-2 image-preview-missing";
|
|
note.textContent = "Preview unavailable: current image path could not be loaded.";
|
|
previewImg.parentElement?.appendChild(note);
|
|
}
|
|
});
|
|
}
|
|
|
|
function initImagePreviewFallbacks(scope = document) {
|
|
scope.querySelectorAll("img.img-thumbnail").forEach((previewImg) => {
|
|
bindImagePreviewFallback(previewImg);
|
|
|
|
if (previewImg.complete && previewImg.getAttribute("src") && previewImg.naturalWidth === 0) {
|
|
previewImg.dispatchEvent(new Event("error"));
|
|
}
|
|
});
|
|
}
|
|
|
|
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) {
|
|
clearImagePreviewError(previewImg);
|
|
bindImagePreviewFallback(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) {
|
|
clearImagePreviewError(previewImg);
|
|
bindImagePreviewFallback(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());
|
|
}
|
|
|
|
function ensureCharacterHint(input) {
|
|
if (!input || (!input.dataset.maxlength && !input.dataset.maxwords)) {
|
|
return null;
|
|
}
|
|
|
|
if (!input.id) {
|
|
input.id = `homeField_${Math.random().toString(36).slice(2, 10)}`;
|
|
}
|
|
|
|
const field = input.closest(".col-md-12, .col-md-9, .col-md-6, .col-md-4, .col-md-3, .col-lg-12, .col-lg-8, .col-lg-6, .col-lg-4, .col-12") || input.parentElement;
|
|
const anchor = input.closest(".input-group") || input;
|
|
const parent = anchor?.parentElement || field;
|
|
if (!field || !anchor || !parent) {
|
|
return null;
|
|
}
|
|
|
|
let hint = field.querySelector(`[data-counter-for="${input.id}"]`);
|
|
if (!hint) {
|
|
hint = document.createElement("small");
|
|
hint.className = "form-text home-limit-counter text-secondary";
|
|
hint.dataset.counterFor = input.id;
|
|
}
|
|
|
|
if (hint.previousElementSibling !== anchor) {
|
|
parent.insertBefore(hint, anchor.nextSibling);
|
|
}
|
|
|
|
return hint;
|
|
}
|
|
|
|
function updateCharacterHint(input) {
|
|
const hint = ensureCharacterHint(input);
|
|
if (!hint) {
|
|
return;
|
|
}
|
|
|
|
const hasWordLimit = Boolean(input.dataset.maxwords);
|
|
const hasCharLimit = Boolean(input.dataset.maxlength);
|
|
|
|
if (hasWordLimit) {
|
|
const maxWords = Number(input.dataset.maxwords);
|
|
const normalized = (input.value || "").replace(/\s+/g, " ").trim();
|
|
const words = normalized ? normalized.split(" ") : [];
|
|
|
|
if (Number.isFinite(maxWords) && maxWords > 0 && words.length > maxWords) {
|
|
input.value = words.slice(0, maxWords).join(" ");
|
|
} else if (normalized !== input.value) {
|
|
input.value = normalized;
|
|
}
|
|
}
|
|
|
|
if (hasCharLimit) {
|
|
const max = Number(input.dataset.maxlength);
|
|
if (Number.isFinite(max) && max > 0 && (input.value || "").length > max) {
|
|
input.value = (input.value || "").slice(0, max);
|
|
}
|
|
}
|
|
|
|
if (hasWordLimit) {
|
|
const maxWords = Number(input.dataset.maxwords);
|
|
const currentWords = input.value ? input.value.split(" ").filter(Boolean).length : 0;
|
|
const currentLength = (input.value || "").length;
|
|
const maxLength = Number(input.dataset.maxlength);
|
|
hint.textContent = hasCharLimit
|
|
? `${currentWords}/${maxWords} words, ${currentLength}/${maxLength} characters`
|
|
: `${currentWords}/${maxWords} words`;
|
|
hint.classList.toggle("text-danger", currentWords >= maxWords || (hasCharLimit && currentLength >= maxLength));
|
|
return;
|
|
}
|
|
|
|
const max = Number(input.dataset.maxlength);
|
|
const length = (input.value || "").length;
|
|
hint.textContent = `${length}/${max} characters`;
|
|
hint.classList.toggle("text-danger", length >= max);
|
|
}
|
|
|
|
function initHomeCharacterCounters(scope = document) {
|
|
scope.querySelectorAll("input[data-maxlength], textarea[data-maxlength], input[data-maxwords], textarea[data-maxwords]").forEach((input) => {
|
|
updateCharacterHint(input);
|
|
|
|
if (input.dataset.counterBound === "true") {
|
|
return;
|
|
}
|
|
|
|
input.dataset.counterBound = "true";
|
|
input.addEventListener("input", () => updateCharacterHint(input));
|
|
});
|
|
}
|
|
</script>
|