Merge branch 'develop' of https://gits.techvanguard.vn/UKSOURCE/cms.lams into feat/duy-20042026-HomepageAbout

This commit is contained in:
2026-04-22 15:40:47 +07:00
7 changed files with 361 additions and 776 deletions
+88 -151
View File
@@ -33,29 +33,23 @@ exports.index = async (req, res) => {
// Prepare data for view
const data = header
? {
topbar: {
contactInfo: {
phone: header.top?.phone || "",
email: header.top?.email || "",
location: header.top?.location || "",
},
socialLinks: header.top?.socialLinks || [],
},
logo: header.logo?.light || "",
signInButton: header.signInButton || {
label: "Sign In",
href: "/signin",
},
ctaButton: header.ctaButton || {
label: "Request Info",
href: "/request",
},
}
: {
topbar: {
contactInfo: {
phone: "",
email: "",
location: "",
},
socialLinks: [],
},
logo: "",
signInButton: { label: "Sign In", href: "/signin" },
ctaButton: { label: "Request Info", href: "/request" },
};
const activeTab = req.query.tab || "topbar";
const activeTab = req.query.tab || "logo";
// Always fetch menu items to ensure they are available even if the user
// switches tabs client-side
@@ -123,14 +117,12 @@ exports.show = async (req, res) => {
// Admin: Create header
exports.store = async (req, res) => {
try {
const { top, offcanvas, menu, logo, ctaButton, status, order } = req.body;
const { logo, signInButton, ctaButton, status, order } = req.body;
const header = new Header({
top,
offcanvas,
menu,
logo,
ctaButton,
logo: logo ? { light: logo } : {},
signInButton: signInButton || { label: "Sign In", href: "/signin" },
ctaButton: ctaButton || {},
status: status || "active",
order: order || 1,
});
@@ -152,129 +144,81 @@ exports.store = async (req, res) => {
// Admin: Update header
exports.update = async (req, res) => {
try {
let { top, topbarJson, offcanvas, menu, logo, ctaButton, status, order } =
req.body;
const { logo, signInButton, ctaButton, status, order } = req.body;
console.log("=== UPDATE REQUEST RECEIVED ===");
console.log("Raw body:", JSON.stringify(req.body, null, 2));
console.log("topbarJson type:", typeof topbarJson);
console.log("topbarJson value:", topbarJson);
// Nếu có topbarJson, parse nó
if (topbarJson && typeof topbarJson === "string") {
try {
const parsedData = JSON.parse(topbarJson);
console.log("✓ Parsed topbarJson successfully:", parsedData);
// Chuyển đổi từ topbarData sang top format
top = {
phone: parsedData.contactInfo?.phone || "",
email: parsedData.contactInfo?.email || "",
location: parsedData.contactInfo?.location || "",
socialLinks: parsedData.socialLinks || [],
};
// Upsert logic: find existing header or prepare to create new one
let header = await Header.findOne().sort({ order: 1 });
let headerId = header?._id;
if (logo) {
updateData.logo = logoData;
}
// Capture BEFORE state for audit logging
const beforeData = header
? JSON.parse(JSON.stringify(header.toObject()))
: {};
console.log(
"Preparing to update header with data:",
JSON.stringify(updateData, null, 2),
);
if (!header) {
console.log("No existing header found, creating new one");
// Create new header document
header = new Header({
logo: logo ? { light: logo } : {},
signInButton: signInButton || { label: "Sign In", href: "/signin" },
ctaButton: ctaButton || {},
status: status || "active",
order: order || 1,
});
await header.save();
console.log("✓ Header created:", header._id);
const updatedHeader = await Header.findByIdAndUpdate(
headerId,
updateData,
{ new: true, runValidators: true },
);
if (!updatedHeader) {
console.error("✗ Header not found with ID:", headerId);
return res.status(404).json({
success: false,
message: "Header not found",
});
}
res.json({
success: true,
message: "Header updated successfully",
data: updatedHeader,
});
} catch (error) {
console.error("✗ Error updating header:", error);
res.status(400).json({
success: false,
message: error.message,
// Audit log for creation
const afterData = JSON.parse(JSON.stringify(header.toObject()));
const changes = diffObject(beforeData, afterData);
if (changes.length > 0) {
await writeAuditLog({
model: "Header",
documentId: header._id,
action: AUDIT_ACTIONS.UPDATE_HEADER,
before: beforeData,
after: afterData,
changes,
req,
});
}
return res.json({
success: true,
message: "Header created successfully",
data: header,
});
}
// Nếu không có id, tìm header đầu tiên hoặc tạo mới
let headerId = req.params.id;
if (!headerId) {
// Tìm header đầu tiên
let header = await Header.findOne().sort({ order: 1 });
if (!header) {
console.log("No existing header found, creating new one");
// Tạo header mới nếu chưa có
header = new Header({
top,
offcanvas,
menu,
logo: logo ? { light: logo } : {},
ctaButton,
status: status || "active",
order: order || 1,
});
await header.save();
console.log("✓ Header created:", header._id);
return res.json({
success: true,
message: "Header created successfully",
data: header,
});
}
headerId = header._id;
console.log("✓ Found existing header:", headerId);
}
// Chuẩn bị dữ liệu logo - merge với dữ liệu cũ
let logoData = {};
// Prepare logo data - merge with existing data
let logoData = header.logo || {};
if (logo) {
// Nếu có logo mới, lấy dữ liệu cũ và update light
const existingHeader = await Header.findById(headerId);
logoData = {
light: logo,
dark: existingHeader?.logo?.dark || "",
alt: existingHeader?.logo?.alt || "",
dark: header.logo?.dark || "",
alt: header.logo?.alt || "",
};
}
// Prepare update data
const updateData = {
top,
offcanvas,
menu,
ctaButton,
status,
order,
logo: logoData,
signInButton: signInButton || header.signInButton,
ctaButton: ctaButton || header.ctaButton,
};
if (logo) {
updateData.logo = logoData;
}
if (status !== undefined) updateData.status = status;
if (order !== undefined) updateData.order = order;
console.log(
"Preparing to update header with data:",
JSON.stringify(updateData, null, 2),
);
// ✅ Capture BEFORE state
const beforeHeader = await Header.findById(headerId);
const beforeData = beforeHeader
? JSON.parse(JSON.stringify(beforeHeader.toObject()))
: {};
// Update existing header
const updatedHeader = await Header.findByIdAndUpdate(headerId, updateData, {
new: true,
runValidators: true,
@@ -288,10 +232,10 @@ exports.update = async (req, res) => {
});
}
// Capture AFTER state
// Capture AFTER state for audit logging
const afterData = JSON.parse(JSON.stringify(updatedHeader.toObject()));
// ✅ AUDIT LOGGING - Header Updated
// Audit logging - Header Updated
const changes = diffObject(beforeData, afterData);
if (changes.length > 0) {
await writeAuditLog({
@@ -384,23 +328,41 @@ exports.destroy = async (req, res) => {
}
};
// Public API: Get active header
// Public API: Get header (returns header regardless of status)
exports.api = async (req, res) => {
try {
const header = await Header.findOne({ status: "active" }).sort({
const header = await Header.findOne().sort({
order: 1,
});
if (!header) {
return res.status(404).json({
success: false,
message: "No active header found",
message: "No header found",
});
}
const baseUrl = (process.env.BACKEND_URL || `${req.protocol}://${req.get('host')}`).replace(/\/$/, '');
const rawLogoPath = header.logo?.light || '';
const logoImage = rawLogoPath.startsWith('http') ? rawLogoPath : `${baseUrl}${rawLogoPath}`;
res.json({
success: true,
data: header,
data: {
logo: {
image: logoImage,
href: "/",
},
signInButton: {
label: header.signInButton?.label || "Sign In",
href: header.signInButton?.href || "/signin",
},
ctaButton: {
label: header.ctaButton?.label || "Request Info",
href: header.ctaButton?.href || "/request",
},
status: header.status || "active",
},
});
} catch (error) {
res.status(500).json({
@@ -410,28 +372,3 @@ exports.api = async (req, res) => {
}
};
// Public API: Get menu tree structure
exports.getMenuTreeAPI = async (req, res) => {
try {
const header = await Header.findOne({ status: "active" }).sort({
order: 1,
});
if (!header || !header.menu) {
return res.status(404).json({
success: false,
message: "No active menu found",
});
}
res.json({
success: true,
data: header.menu,
});
} catch (error) {
res.status(500).json({
success: false,
message: error.message,
});
}
};
+2 -1
View File
@@ -21,6 +21,7 @@ const buildMenuTree = (items, parentId = null, isPublic = false) => {
title: item.title,
url: item.url,
type: item.type,
status: item.status || 'active',
};
}
@@ -196,7 +197,7 @@ exports.reorder = async (req, res) => {
// Public API: Get active menu as clean tree
exports.api = async (req, res) => {
try {
const items = await HeaderMenu.find({ status: "active" }).sort({ order: 1 });
const items = await HeaderMenu.find().sort({ order: 1 });
const tree = buildMenuTree(items, null, true);
res.json({ success: true, data: tree });
} catch (error) {
+12 -77
View File
@@ -1,84 +1,7 @@
const mongoose = require("mongoose");
const socialLinkSchema = new mongoose.Schema(
{
platform: {
type: String,
required: true,
enum: ["linkedin", "twitter", "instagram", "youtube", "facebook"],
},
url: {
type: String,
required: true,
},
icon: String,
order: {
type: Number,
default: 0,
},
},
{ _id: false },
);
const languageSchema = new mongoose.Schema(
{
name: {
type: String,
required: true,
},
value: {
type: String,
required: true,
},
},
{ _id: false },
);
const menuItemSchema = new mongoose.Schema(
{
label: {
type: String,
required: true,
},
href: {
type: String,
required: true,
},
icon: String,
order: {
type: Number,
default: 0,
},
children: [this],
},
{ _id: false },
);
const headerSchema = new mongoose.Schema(
{
// Top bar
top: {
phone: String,
email: String,
location: String,
socialLinks: [socialLinkSchema],
languages: [languageSchema],
},
// Offcanvas
offcanvas: {
description: String,
contactInfo: {
address: String,
email: String,
workingHours: String,
phone: String,
},
},
// Menu
menu: [menuItemSchema],
// Logo
logo: {
light: String,
@@ -86,6 +9,18 @@ const headerSchema = new mongoose.Schema(
alt: String,
},
// Sign In Button
signInButton: {
label: {
type: String,
default: "Sign In",
},
href: {
type: String,
default: "/signin",
},
},
// CTA Button
ctaButton: {
label: String,
Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

-3
View File
@@ -43,9 +43,6 @@ router.get("/api/about", aboutController.api);
// Header API route
router.get("/api/header", headerController.api);
// Menu Tree API route (for frontend)
router.get("/api/menu-tree", headerController.getMenuTreeAPI);
// Header Menu New Module API
router.get("/api/header-menu", headerMenuController.api);
+190
View File
@@ -0,0 +1,190 @@
require('dotenv').config();
const mongoose = require('mongoose');
const slugify = require('slugify');
// Data from lams/app/components/layout/Header/header.json
const headerJsonData = {
"logo": {
"image": "/uploads/header/logo.jp",
"href": "/"
},
"navLinks": [
{
"label": "About Us",
"href": "/about",
"children": [
{
"label": "History & Milestones",
"href": "/about/history"
},
{
"label": "Accreditation",
"href": "/about/accreditation"
},
{
"label": "Partnerships",
"href": "/about/partnerships"
}
]
},
{
"label": "Programs",
"href": "/programmes"
},
{
"label": "Student Support",
"href": "/student-support"
},
{
"label": "Admissions & Tuition",
"href": "/admissions"
},
{
"label": "Blog",
"href": "/blog"
},
{
"label": "Contact",
"href": "/contact"
}
],
"actions": {
"signIn": {
"label": "Sign In",
"href": "/signin"
},
"cta": {
"label": "Request Info",
"href": "/request"
}
}
};
async function seedHeader() {
try {
console.log('=== Seed Header Data from JSON ===');
console.log('Connecting to MongoDB...');
await mongoose.connect(process.env.MONGODB_URI);
console.log('✓ Connected to MongoDB');
const Header = require('../models/header');
const HeaderMenu = require('../models/headerMenu');
// Step 1: Seed Header document
console.log('\nStep 1: Seeding Header document...');
const existingHeader = await Header.findOne();
if (existingHeader) {
console.log('⚠ Header document already exists. Updating...');
existingHeader.logo = {
light: headerJsonData.logo.image,
dark: '',
alt: 'LAMS Logo',
};
existingHeader.signInButton = {
label: headerJsonData.actions.signIn.label,
href: headerJsonData.actions.signIn.href,
};
existingHeader.ctaButton = {
label: headerJsonData.actions.cta.label,
href: headerJsonData.actions.cta.href,
style: 'primary',
};
existingHeader.status = 'active';
await existingHeader.save();
console.log('✓ Header document updated');
} else {
const header = new Header({
logo: {
light: headerJsonData.logo.image,
dark: '',
alt: 'LAMS Logo',
},
signInButton: {
label: headerJsonData.actions.signIn.label,
href: headerJsonData.actions.signIn.href,
},
ctaButton: {
label: headerJsonData.actions.cta.label,
href: headerJsonData.actions.cta.href,
style: 'primary',
},
status: 'active',
order: 1,
});
await header.save();
console.log('✓ Header document created');
}
// Step 2: Seed HeaderMenu documents
console.log('\nStep 2: Seeding HeaderMenu documents...');
const existingMenuCount = await HeaderMenu.countDocuments();
if (existingMenuCount > 0) {
console.log(`⚠ Found ${existingMenuCount} existing menu items. Skipping menu seed.`);
console.log(' To re-seed menu, delete existing menu items first.');
} else {
let order = 0;
for (const navLink of headerJsonData.navLinks) {
// Create parent menu item
const parentSlug = slugify(navLink.label, { lower: true, strict: true });
const parentMenu = new HeaderMenu({
title: navLink.label,
slug: parentSlug,
url: navLink.href,
parentId: null,
order: order++,
status: 'active',
type: 'internal',
});
await parentMenu.save();
console.log(` ✓ Created parent menu: ${navLink.label}`);
// Create children menu items if they exist
if (navLink.children && navLink.children.length > 0) {
let childOrder = 0;
for (const child of navLink.children) {
const childSlug = slugify(child.label, { lower: true, strict: true });
const childMenu = new HeaderMenu({
title: child.label,
slug: childSlug,
url: child.href,
parentId: parentMenu._id,
order: childOrder++,
status: 'active',
type: 'internal',
});
await childMenu.save();
console.log(` ✓ Created child menu: ${child.label}`);
}
}
}
console.log(`✓ Created ${order} parent menu items with their children`);
}
console.log('\n=== Seed Complete ===');
await mongoose.disconnect();
console.log('✓ Disconnected from MongoDB');
} catch (error) {
console.error('✗ Seed failed:', error);
process.exit(1);
}
}
// Run seed if this script is executed directly
if (require.main === module) {
seedHeader()
.then(() => process.exit(0))
.catch((error) => {
console.error(error);
process.exit(1);
});
}
module.exports = seedHeader;
+69 -544
View File
@@ -12,25 +12,13 @@
<div class="col-12">
<div class="content-with-fixed-buttons">
<!-- Hidden inputs for JSON data -->
<input type="hidden" name="topbarJson" id="topbarJson" />
<input type="hidden" name="logo" id="logoInput" />
<input type="hidden" name="activeTab" id="activeTabInput" value="topbar" />
<input type="hidden" name="menuUpdates" id="menuUpdates" />
<input type="hidden" name="activeTab" id="activeTabInput" value="logo" />
<!-- 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 <%= activeTab === 'topbar' ? 'active' : '' %>"
data-bs-toggle="tab"
href="#topbar"
role="tab"
>
<i class="fas fa-bars me-2"></i>Topbar
</a>
</li>
<li class="nav-item">
<a
class="nav-link <%= activeTab === 'logo' ? 'active' : '' %>"
@@ -41,6 +29,16 @@
<i class="fas fa-image me-2"></i>Logo
</a>
</li>
<li class="nav-item">
<a
class="nav-link <%= activeTab === 'buttons' ? 'active' : '' %>"
data-bs-toggle="tab"
href="#buttons"
role="tab"
>
<i class="fas fa-mouse-pointer me-2"></i>Buttons
</a>
</li>
<li class="nav-item">
<a
class="nav-link <%= activeTab === 'menu' ? 'active' : '' %>"
@@ -56,79 +54,6 @@
<div class="card-body">
<div class="tab-content">
<!-- Topbar Tab -->
<div class="tab-pane fade <%= activeTab === 'topbar' ? 'show active' : '' %>" id="topbar" role="tabpanel">
<div class="row g-4">
<!-- Contact Information -->
<div class="col-md-12">
<div class="card border shadow-sm">
<div class="card-header bg-white">
<h6 class="mb-0">
<i class="fas fa-phone me-2"></i>Contact Information
</h6>
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-md-6">
<label class="form-label fw-medium">Phone Number</label>
<input
type="text"
class="form-control"
id="contactPhone"
value="<%= data.topbar.contactInfo.phone %>"
placeholder="+1 234 567 890"
/>
</div>
<div class="col-md-6">
<label class="form-label fw-medium">Email Address</label>
<input
type="email"
class="form-control"
id="contactEmail"
value="<%= data.topbar.contactInfo.email || '' %>"
placeholder="info@example.com"
/>
</div>
<div class="col-md-12">
<label class="form-label fw-medium">Location</label>
<input
type="text"
class="form-control"
id="contactLocation"
value="<%= data.topbar.contactInfo.location || '' %>"
placeholder="69 Street, 5th Avenue LA, United States"
/>
</div>
</div>
</div>
</div>
</div>
<!-- Social Links -->
<div class="col-md-12">
<div class="card border shadow-sm">
<div class="card-header bg-white d-flex justify-content-between align-items-center">
<h6 class="mb-0">
<i class="fas fa-share-alt me-2"></i>Social Media Links
</h6>
<button
type="button"
class="btn btn-primary btn-sm"
id="addSocialLink"
>
<i class="fas fa-plus me-1"></i>Add Social Link
</button>
</div>
<div class="card-body">
<div id="socialLinksContainer" class="social-links-sortable">
<!-- Social links will be populated here -->
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Logo Tab -->
<div class="tab-pane fade <%= activeTab === 'logo' ? 'show active' : '' %>" id="logo" role="tabpanel">
<div class="row g-4">
@@ -179,6 +104,52 @@
</div>
</div>
<!-- Buttons Tab -->
<div class="tab-pane fade <%= activeTab === 'buttons' ? 'show active' : '' %>" id="buttons" role="tabpanel">
<div class="row g-4">
<!-- Sign In Button -->
<div class="col-md-6">
<div class="card border shadow-sm">
<div class="card-header bg-white">
<h6 class="mb-0"><i class="fas fa-sign-in-alt me-2"></i>Sign In Button</h6>
</div>
<div class="card-body">
<div class="mb-3">
<label class="form-label fw-medium">Label</label>
<input type="text" class="form-control" id="signInLabel"
value="<%= data.signInButton.label %>" placeholder="Sign In" />
</div>
<div class="mb-3">
<label class="form-label fw-medium">URL</label>
<input type="text" class="form-control" id="signInHref"
value="<%= data.signInButton.href %>" placeholder="/signin" />
</div>
</div>
</div>
</div>
<!-- CTA Button -->
<div class="col-md-6">
<div class="card border shadow-sm">
<div class="card-header bg-white">
<h6 class="mb-0"><i class="fas fa-mouse-pointer me-2"></i>CTA Button</h6>
</div>
<div class="card-body">
<div class="mb-3">
<label class="form-label fw-medium">Label</label>
<input type="text" class="form-control" id="ctaLabel"
value="<%= data.ctaButton.label %>" placeholder="Request Info" />
</div>
<div class="mb-3">
<label class="form-label fw-medium">URL</label>
<input type="text" class="form-control" id="ctaHref"
value="<%= data.ctaButton.href %>" placeholder="/request" />
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Menu Structure Tab -->
<div class="tab-pane fade <%= activeTab === 'menu' ? 'show active' : '' %>" id="menu" role="tabpanel">
<%- include('menu') %>
@@ -202,12 +173,6 @@
<script>
document.addEventListener('DOMContentLoaded', function () {
let socialLinkIndex = 0;
// Initialize social links from data
initializeSocialLinks();
updateHiddenInputs();
// Safely remove any lingering modal backdrops on page load/navigation
function cleanupModals() {
// Basic reset
@@ -291,7 +256,7 @@
}
// Attach listeners to all inputs for change detection
const headerInputs = document.querySelectorAll('#topbar input, #logo input');
const headerInputs = document.querySelectorAll('#logo input, #buttons input');
headerInputs.forEach(input => {
input.addEventListener('input', markChanged);
input.addEventListener('change', markChanged);
@@ -338,7 +303,6 @@
if (saveHeaderBtn) {
saveHeaderBtn.addEventListener('click', async function (e) {
console.log('=== TRACE: saveHeaderBtn Clicked (Unified) ===');
updateHiddenInputs();
const submitBtn = this;
const originalText = submitBtn.innerHTML;
@@ -346,11 +310,17 @@
submitBtn.disabled = true;
try {
// 1. Collect and Save Topbar & Logo
// 1. Collect and Save Header Data (Logo + Buttons)
const headerData = {
topbarJson: document.getElementById('topbarJson').value,
logo: document.getElementById('logoInput').value,
activeTab: document.getElementById('activeTabInput').value
logo: document.getElementById('logoImage').value,
signInButton: {
label: document.getElementById('signInLabel').value,
href: document.getElementById('signInHref').value,
},
ctaButton: {
label: document.getElementById('ctaLabel').value,
href: document.getElementById('ctaHref').value,
},
};
const headerResponse = await fetch('/admin/header/update', {
@@ -382,7 +352,7 @@
}
} catch (error) {
console.error('=== TRACE: Unified Save ERROR ===', error);
showNotification('Lỗi: ' + error.message, 'error');
showNotification('Error: ' + error.message, 'error');
} finally {
submitBtn.innerHTML = originalText;
submitBtn.disabled = false;
@@ -390,416 +360,6 @@
});
}
function updateHiddenInputs() {
const topbarData = {
contactInfo: {
phone: document.getElementById('contactPhone').value || '',
email: document.getElementById('contactEmail').value || '',
location: document.getElementById('contactLocation').value || ''
},
socialLinks: []
};
// Collect social links
document.querySelectorAll('.social-platform').forEach((input) => {
const platform = input.value.trim();
const urlInput = document.querySelector(`.social-url[data-index="${input.dataset.index}"]`);
const iconInput = document.querySelector(`.social-icon[data-index="${input.dataset.index}"]`);
const url = urlInput ? urlInput.value.trim() : '';
const icon = iconInput ? iconInput.value.trim() : '';
if (platform && url) {
topbarData.socialLinks.push({
platform: platform,
url: url,
icon: icon
});
}
});
document.getElementById('topbarJson').value = JSON.stringify(topbarData);
document.getElementById('logoInput').value = document.getElementById('logoImage').value || '';
try {
const menuUpdates = collectMenuUpdates();
document.getElementById('menuUpdates').value = JSON.stringify(menuUpdates);
} catch (e) {
console.error('Error collecting menu updates:', e);
document.getElementById('menuUpdates').value = '[]';
}
}
/**
* Collect menu updates from the menu tree
* Returns an empty array if no menu tree exists
*/
function collectMenuUpdates() {
// For now, return empty array as menu structure management is coming soon
// This function can be expanded when menu tree functionality is implemented
return [];
}
function initializeSocialLinks() {
const container = document.getElementById('socialLinksContainer');
if (!container) return;
// Extract social links safely from EJS data
let socialLinks = [];
try {
socialLinks = <%- JSON.stringify(data.topbar.socialLinks || []) %>;
} catch (e) {
console.error('Error parsing social links data:', e);
}
if (socialLinks.length === 0) {
// Add default platforms if no social links exist
const platforms = ['linkedin', 'twitter', 'instagram', 'youtube'];
platforms.forEach((platform, index) => {
addSocialLinkRow(platform, '', `fa-brands fa-${platform}`, index);
});
socialLinkIndex = platforms.length;
} else {
// Load existing social links
socialLinks.forEach((social, index) => {
const platform = social.platform || '';
const url = social.url || '';
const icon = social.icon || '';
addSocialLinkRow(platform, url, icon, index);
});
socialLinkIndex = socialLinks.length;
}
}
function addSocialLinkRow(platform, url, icon, index) {
const container = document.getElementById('socialLinksContainer');
const socialLink = document.createElement('div');
socialLink.className = 'card mb-3 border social-link-item';
socialLink.dataset.platform = platform;
// Escape HTML to prevent XSS
const escapedPlatform = (platform || '').replace(/[&<>"']/g, char => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;'
}[char]));
const escapedUrl = (url || '').replace(/[&<>"']/g, char => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;'
}[char]));
const escapedIcon = (icon || '').replace(/[&<>"']/g, char => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;'
}[char]));
socialLink.innerHTML = `
<div class="card-body">
<div class="row g-3 align-items-end">
<div class="col-md-1 d-flex justify-content-center align-items-center w-auto">
<label class="form-label fw-medium">&nbsp;</label>
<div class="drag-handle" title="Drag to reorder" style="cursor: grab; font-size: 1.2rem; color: #999; user-select: none;">
<i class="fas fa-grip-vertical"></i>
</div>
</div>
<div class="col-md-2">
<label class="form-label fw-medium">Platform</label>
<input type="text" class="form-control social-platform" value="${escapedPlatform}" data-index="${index}" disabled />
</div>
<div class="col-md-4">
<label class="form-label fw-medium">URL</label>
<input type="text" class="form-control social-url" value="${escapedUrl}" data-index="${index}" placeholder="https://..." />
</div>
<div class="col-md-3">
<label class="form-label fw-medium">Icon Class</label>
<input type="text" class="form-control social-icon" value="${escapedIcon}" data-index="${index}" />
</div>
<div class="col-md-2">
<label class="form-label">&nbsp;</label>
<div class="btn-group w-100" role="group">
<button type="button" class="btn btn-outline-primary btn-sm edit-social-link mx-3 rounded" style="transform: none" data-index="${index}" title="Edit">
<i class="fas fa-edit"></i>
</button>
<button type="button" class="btn btn-outline-danger btn-sm remove-social-link rounded" data-index="${index}" title="Delete">
<i class="fas fa-trash"></i>
</button>
</div>
</div>
</div>
</div>
`;
container.appendChild(socialLink);
}
/* ============================================
DRAG & DROP STYLING
============================================ */
const dragDropStyles = document.createElement('style');
dragDropStyles.textContent = `
.social-link-item {
transition: transform 0.2s ease;
}
.drag-handle {
display: flex;
align-items: center;
justify-content: center;
padding: 8px;
border-radius: 4px;
transition: all 0.2s ease;
cursor: grab !important;
}
.drag-handle:active {
cursor: grabbing !important;
}
.drag-handle:hover {
background-color: #f0f0f0;
color: #0d6efd;
}
/* SortableJS Classes */
.social-ghost {
opacity: 0.4;
border: 2px dashed #0d6efd !important;
background-color: #f8f9fa !important;
}
.social-chosen {
background-color: #eef3ff !important;
box-shadow: 0 5px 15px rgba(0,0,0,0.1) !important;
}
.social-drag {
opacity: 0.9;
}
/* Fix Modal Freeze & Z-Index issues */
body.modal-open {
overflow: hidden !important;
padding-right: 0 !important;
}
`;
document.head.appendChild(dragDropStyles);
document.addEventListener('click', function (e) {
if (e.target.closest('.remove-social-link')) {
e.preventDefault();
const btn = e.target.closest('.remove-social-link');
const index = btn.dataset.index;
const platformInput = document.querySelector(`.social-platform[data-index="${index}"]`);
const platform = platformInput.value;
if (confirm(`Delete ${platform} social link?`)) {
deleteSocialLink(platform, btn.closest('.card'));
}
}
if (e.target.closest('.edit-social-link')) {
e.preventDefault();
const btn = e.target.closest('.edit-social-link');
const index = btn.dataset.index;
const platformInput = document.querySelector(`.social-platform[data-index="${index}"]`);
const urlInput = document.querySelector(`.social-url[data-index="${index}"]`);
const iconInput = document.querySelector(`.social-icon[data-index="${index}"]`);
const platform = platformInput.value;
const url = urlInput.value;
const icon = iconInput.value;
showEditSocialLinkModal(platform, url, icon);
}
if (e.target.closest('#addSocialLink')) {
e.preventDefault();
showAddSocialLinkModal();
}
});
function deleteSocialLink(platform, cardElement) {
if (confirm(`Delete ${platform} social link?`)) {
cardElement.remove();
updateHiddenInputs();
markChanged();
}
}
function showAddSocialLinkModal() {
const modal = document.createElement('div');
modal.className = 'modal fade';
modal.id = 'addSocialLinkModal';
modal.setAttribute('tabindex', '-1');
modal.innerHTML = `
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Add New Social Link</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label fw-medium">Platform <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="newSocialPlatform" placeholder="e.g., linkedin, twitter, instagram, youtube, facebook" />
</div>
<div class="mb-3">
<label class="form-label fw-medium">URL <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="newSocialUrl" placeholder="https://..." />
</div>
<div class="mb-3">
<label class="form-label fw-medium">Icon Class</label>
<input type="text" class="form-control" id="newSocialIcon" placeholder="e.g., fa-brands fa-linkedin" />
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="saveSocialLink">Save</button>
</div>
</div>
</div>
`;
document.body.appendChild(modal);
const bsModal = new bootstrap.Modal(modal);
document.getElementById('saveSocialLink').addEventListener('click', function() {
const platform = document.getElementById('newSocialPlatform').value.trim();
const url = document.getElementById('newSocialUrl').value.trim();
const icon = document.getElementById('newSocialIcon').value.trim();
if (!platform) {
alert('Vui lòng nhập tên nền tảng');
return;
}
if (!url) {
alert('Vui lòng nhập URL');
return;
}
// Check if platform already exists
const existingPlatforms = Array.from(document.querySelectorAll('.social-platform')).map(el => el.value);
if (existingPlatforms.includes(platform)) {
alert(`${platform} đã tồn tại`);
return;
}
addSocialLinkViaAPI(platform, url, icon || `fa-brands fa-${platform}`, bsModal, modal);
});
bsModal.show();
// Focus on platform input
setTimeout(() => {
document.getElementById('newSocialPlatform').focus();
}, 500);
}
function showEditSocialLinkModal(platform, url, icon) {
const modal = document.createElement('div');
modal.className = 'modal fade';
modal.id = 'editSocialLinkModal';
modal.setAttribute('tabindex', '-1');
const platformDisplay = platform ? platform.charAt(0).toUpperCase() + platform.slice(1) : 'Social';
modal.innerHTML = `
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Edit ${platformDisplay} Link</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label fw-medium">Platform <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="editSocialPlatform" value="${platform}" placeholder="e.g., linkedin, twitter, instagram" />
</div>
<div class="mb-3">
<label class="form-label fw-medium">URL <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="editSocialUrl" value="${url}" placeholder="https://www.example.com" />
</div>
<div class="mb-3">
<label class="form-label fw-medium">Icon Class</label>
<input type="text" class="form-control" id="editSocialIcon" value="${icon}" />
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="updateSocialLink">Update</button>
</div>
</div>
</div>
`;
document.body.appendChild(modal);
const bsModal = new bootstrap.Modal(modal);
document.getElementById('updateSocialLink').addEventListener('click', function() {
const newPlatform = document.getElementById('editSocialPlatform').value.trim();
const newUrl = document.getElementById('editSocialUrl').value.trim();
const newIcon = document.getElementById('editSocialIcon').value.trim();
if (!newPlatform) {
alert('Vui lòng nhập tên nền tảng');
return;
}
if (!newUrl) {
alert('Vui lòng nhập URL');
return;
}
updateSocialLinkViaAPIModal(platform, newPlatform, newUrl, newIcon, bsModal, modal);
});
bsModal.show();
// Focus on platform input
setTimeout(() => {
document.getElementById('editSocialPlatform').focus();
}, 500);
}
function addSocialLinkViaAPI(platform, url, icon, modal, modalElement) {
addSocialLinkRow(platform, url, icon, Date.now());
updateHiddenInputs();
markChanged();
modal.hide();
modalElement.remove();
}
function updateSocialLinkViaAPIModal(oldPlatform, newPlatform, url, icon, modal, modalElement) {
newPlatform = newPlatform.toLowerCase().trim();
// Update the input fields in the DOM
const platformInputs = document.querySelectorAll(`.social-platform`);
const urlInputs = document.querySelectorAll(`.social-url`);
const iconInputs = document.querySelectorAll(`.social-icon`);
let found = false;
for (let i = 0; i < platformInputs.length; i++) {
if (platformInputs[i].value.toLowerCase() === oldPlatform.toLowerCase()) {
platformInputs[i].value = newPlatform;
urlInputs[i].value = url;
if (iconInputs[i]) iconInputs[i].value = icon;
found = true;
break;
}
}
if (found) {
modal.hide();
modalElement.remove();
updateHiddenInputs();
markChanged();
}
}
/**
* Show toast notification at top of page
@@ -990,41 +550,6 @@
previewContainer.appendChild(img);
}
}
// Initialize Sortable for Social Links
const socialLinksContainer = document.getElementById('socialLinksContainer');
const SortableLib = window.Sortable || Sortable;
console.log('=== TRACE: Social Sortable Init ===', {
containerExists: !!socialLinksContainer,
sortableDefined: typeof SortableLib !== 'undefined'
});
if (socialLinksContainer && typeof SortableLib !== 'undefined') {
try {
new SortableLib(socialLinksContainer, {
animation: 150,
handle: '.drag-handle',
ghostClass: 'social-ghost',
chosenClass: 'social-chosen',
dragClass: 'social-drag',
forceFallback: true, // Use transition-based dragging for better compatibility
onStart: function() {
console.log('=== TRACE: Social Drag Started ===');
},
onEnd: function() {
console.log('=== TRACE: Social Drag Ended ===');
updateHiddenInputs();
markChanged();
}
});
console.log('=== TRACE: Sortable initialized for socialLinksContainer ===');
} catch (err) {
console.error('=== TRACE: Sortable Init Error ===', err);
}
} else {
console.warn('SortableJS not loaded or socialLinksContainer not found');
}
});
</script>