Merge pull request 'feat/huy-03022026-add-headermenu-api-crud-management' (#18) from feat/huy-03022026-add-headermenu-api-crud-management into main

Reviewed-on: UKSOURCE/cms.hailearning.edu.vn#18
This commit is contained in:
2026-02-05 02:17:52 +00:00
35 changed files with 3889 additions and 1728 deletions

View File

@@ -0,0 +1,127 @@
/**
* CMS Component: Buttons
* Overrides Bootstrap buttons to match CMS design language
*/
.btn {
border-radius: var(--border-radius);
font-weight: var(--font-weight-medium);
padding: 0.5rem 1.25rem;
transition: var(--transition-base);
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
height: 42px; /* Standardize height */
}
.btn-sm {
padding: 0.25rem 0.75rem;
font-size: var(--font-size-sm);
height: 32px;
}
.btn-lg {
padding: 0.75rem 1.5rem;
font-size: var(--font-size-lg);
height: 52px;
}
/* Primary Button */
.btn-primary {
background-color: var(--primary-color);
border-color: var(--primary-color);
color: var(--text-white);
}
.btn-primary:hover, .btn-primary:focus {
background-color: var(--primary-dark);
border-color: var(--primary-dark);
color: var(--text-white);
transform: translateY(-1px);
box-shadow: var(--shadow-md);
}
/* Outline Primary */
.btn-outline-primary {
color: var(--primary-color);
border-color: var(--primary-color);
}
.btn-outline-primary:hover {
background-color: var(--primary-color);
color: var(--text-white);
}
/* White / Icon Button */
.btn-white {
background-color: var(--bg-card);
border: 1px solid var(--border-color);
color: var(--text-main);
}
.btn-white:hover {
background-color: #f8f9fa;
border-color: #dee2e6;
box-shadow: var(--shadow-sm);
}
/* Special Button Effects (Shine effect from main.ejs) */
.btn-shine {
position: relative;
overflow: hidden;
}
.btn-shine::after {
content: "";
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent);
transition: all 0.5s ease;
}
.btn-shine:hover::after {
left: 100%;
}
/* Action Button Group (Like Topbar screenshot) */
.btn-group-action {
background-color: #fff;
border: 1px solid var(--border-color);
border-radius: var(--border-radius-lg);
box-shadow: var(--shadow-sm);
overflow: hidden;
display: inline-flex;
}
.btn-group-action .btn {
border: none;
border-radius: 0;
padding: 0.5rem 0.85rem;
background: transparent;
height: 38px;
width: 42px;
}
.btn-group-action .btn:not(:last-child) {
border-right: 1px solid var(--border-color);
}
.btn-group-action .btn:hover {
background-color: #f8f9fa;
transform: none;
box-shadow: none;
}
/* Icon alignment inside buttons */
.btn i {
font-size: 0.9em;
}
/* Icon specific colors for actions */
.text-action-add { color: #0d6efd !important; }
.text-action-edit { color: #ffc107 !important; }
.text-action-delete { color: #dc3545 !important; }

View File

@@ -0,0 +1,39 @@
/**
* CMS Component: Cards
* Standardizes administrative dashboard cards
*/
.card {
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
box-shadow: var(--shadow-sm);
transition: var(--transition-base);
overflow: hidden;
}
.card:hover {
box-shadow: var(--shadow-md);
}
.card-header {
background-color: #fff;
border-bottom: 1px solid var(--border-color);
padding: var(--spacing-3) var(--spacing-4);
font-weight: var(--font-weight-bold);
}
.card-title {
margin-bottom: 0;
font-size: 1.1rem;
color: var(--text-main);
}
.card-body {
padding: var(--spacing-4);
}
.card-footer {
background-color: #fcfcfc;
border-top: 1px solid var(--border-color);
padding: var(--spacing-2) var(--spacing-4);
}

View File

@@ -0,0 +1,50 @@
/**
* CMS Component: Forms
* Standardizes inputs, labels and validation messages
*/
.form-label {
font-weight: var(--font-weight-medium);
margin-bottom: var(--spacing-2);
color: var(--text-main);
font-size: var(--font-size-sm);
}
.form-control, .form-select {
border-radius: var(--border-radius);
border: 1px solid var(--border-color);
padding: 0.6rem 1rem;
font-size: var(--font-size-base);
transition: var(--transition-base);
}
.form-control:focus, .form-select:focus {
border-color: var(--primary-color);
box-shadow: 0 0 0 0.2rem rgba(188, 159, 105, 0.15);
outline: none;
}
.input-group-text {
background-color: #f8f9fa;
border-color: var(--border-color);
color: var(--text-muted);
}
/* Form Helper Text */
.form-text {
font-size: var(--font-size-xs);
color: var(--text-muted);
margin-top: var(--spacing-1);
}
/* Validation Styles */
.invalid-feedback {
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
}
/* Custom Checkbox/Radio */
.form-check-input:checked {
background-color: var(--primary-color);
border-color: var(--primary-color);
}

View File

@@ -0,0 +1,45 @@
/**
* CMS Component: Modals
* Standardizes modal spacing and appearance
*/
.modal-content {
border: none;
border-radius: var(--border-radius-lg);
box-shadow: var(--shadow-lg);
}
.modal-header {
border-bottom: 1px solid var(--border-color);
padding: var(--spacing-4);
background-color: #fff;
border-radius: var(--border-radius-lg) var(--border-radius-lg) 0 0;
}
.modal-title {
font-weight: var(--font-weight-bold);
color: var(--text-main);
font-size: 1.15rem;
}
.modal-body {
padding: var(--spacing-4);
}
.modal-footer {
border-top: 1px solid var(--border-color);
padding: var(--spacing-3) var(--spacing-4);
gap: var(--spacing-2);
}
/* Modal sizing standards */
.modal-dialog-centered {
display: flex;
align-items: center;
min-height: calc(100% - var(--bs-modal-margin) * 2);
}
/* Fix for backdrop issue reported in previous conversations */
.modal-backdrop.show {
opacity: 0.5;
}

View File

@@ -0,0 +1,41 @@
/**
* CMS Component: Tables
* Standardizes data tables listing
*/
.table {
margin-bottom: 0;
}
.table thead th {
background-color: #f8f9fa;
border-bottom: 2px solid var(--border-color);
color: var(--text-muted);
font-weight: var(--font-weight-bold);
font-size: var(--font-size-xs);
text-transform: uppercase;
letter-spacing: 0.025em;
padding: var(--spacing-3) var(--spacing-4);
}
.table tbody td {
padding: var(--spacing-3) var(--spacing-4);
vertical-align: middle;
border-bottom: 1px solid var(--border-color);
}
.table-hover tbody tr:hover {
background-color: rgba(188, 159, 105, 0.05);
}
/* Badge in table */
.badge {
padding: 0.4em 0.6em;
border-radius: var(--border-radius-sm);
font-weight: var(--font-weight-medium);
}
.bg-soft-success { background-color: var(--success-soft); color: var(--success-color); border: 1px solid rgba(40, 167, 69, 0.2); }
.bg-soft-danger { background-color: var(--danger-soft); color: var(--danger-color); border: 1px solid rgba(220, 53, 69, 0.2); }
.bg-soft-warning { background-color: var(--warning-soft); color: var(--warning-color); border: 1px solid rgba(255, 193, 7, 0.2); }
.bg-soft-info { background-color: var(--info-soft); color: var(--info-color); border: 1px solid rgba(23, 162, 184, 0.2); }

101
assets/css/layout.css Normal file
View File

@@ -0,0 +1,101 @@
/**
* CMS Global Layout
* Navbar, Footer, and Page structures
*/
body {
font-family: var(--font-family);
background-color: var(--bg-body);
min-height: 100vh;
display: flex;
flex-direction: column;
color: var(--text-main);
}
main {
flex: 1;
padding: var(--spacing-4) 0;
}
/* Navbar Customization */
.navbar {
background-color: rgba(255, 255, 255, 0.95);
box-shadow: var(--shadow-header);
padding: 0.75rem 0;
transition: var(--transition-base);
}
.navbar-brand {
font-weight: var(--font-weight-bold);
color: var(--primary-color) !important;
}
.nav-link {
color: var(--text-main);
font-weight: var(--font-weight-medium);
padding: 0.5rem 1rem;
transition: var(--transition-base);
font-size: 0.95rem;
}
.nav-link:hover, .nav-link.active {
color: var(--primary-color) !important;
}
/* Page Containers */
.page-header {
margin-bottom: var(--spacing-4);
}
.page-content {
background-color: var(--bg-card);
padding: var(--spacing-4);
border-radius: var(--border-radius);
box-shadow: var(--shadow-sm);
}
/* Footer Customization */
footer {
background: linear-gradient(90deg, var(--primary-dark), var(--primary-color), var(--primary-light), #d4c4a8);
color: var(--text-white);
margin-top: auto;
padding: var(--spacing-4) 0;
}
footer a {
color: rgba(255, 255, 255, 0.8) !important;
text-decoration: none;
transition: var(--transition-base);
}
footer a:hover {
color: var(--text-white) !important;
}
/* Utility: Fixed Bottom Actions (for Forms/Modals) */
.fixed-bottom-buttons {
position: fixed;
bottom: 0;
right: 0;
padding: var(--spacing-3);
z-index: 1000;
display: flex;
gap: var(--spacing-2);
}
/* Dropdown Customization */
.dropdown-menu {
border: none;
box-shadow: var(--shadow-lg);
border-radius: var(--border-radius);
margin-top: 0.5rem;
}
.dropdown-item:hover {
background-color: var(--primary-soft);
}
.dropdown-item.active {
background-color: var(--primary-color);
color: var(--text-white);
}

67
assets/css/variables.css Normal file
View File

@@ -0,0 +1,67 @@
/**
* CMS Design System Variables
* Standardized colors, spacing, and typography
*/
:root {
/* Primary Colors (Gold/Cinnamon) */
--primary-color: #bc9f69;
--primary-rgb: 188, 159, 105;
--primary-light: #d4c4a8;
--primary-dark: #a68b58;
--primary-soft: rgba(188, 159, 105, 0.1);
/* Secondary Colors */
--secondary-color: #6c757d;
--secondary-soft: rgba(108, 117, 125, 0.1);
/* Status Colors */
--success-color: #28a745;
--success-soft: rgba(40, 167, 69, 0.1);
--warning-color: #ffc107;
--warning-soft: rgba(255, 193, 7, 0.1);
--danger-color: #dc3545;
--danger-soft: rgba(220, 53, 69, 0.1);
--info-color: #17a2b8;
--info-soft: rgba(23, 162, 184, 0.1);
/* Neutral Colors / Backgrounds */
--bg-body: #f5f7fa;
--bg-card: #ffffff;
--bg-header: #ffffff;
--border-color: #e9ecef;
--text-main: #333333;
--text-muted: #6c757d;
--text-white: #ffffff;
/* Typography */
--font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
--font-size-base: 1rem;
--font-size-sm: 0.875rem;
--font-size-xs: 0.75rem;
--font-size-lg: 1.25rem;
--font-weight-normal: 400;
--font-weight-medium: 500;
--font-weight-bold: 700;
/* Spacing & Borders */
--spacing-1: 0.25rem;
--spacing-2: 0.5rem;
--spacing-3: 1rem;
--spacing-4: 1.5rem;
--spacing-5: 3rem;
--border-radius: 8px;
--border-radius-sm: 4px;
--border-radius-lg: 12px;
--border-radius-full: 50px;
/* Shadows & Elevation */
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.08);
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.05);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
--shadow-header: 0 2px 10px rgba(0, 0, 0, 0.05);
/* Transitions */
--transition-base: all 0.2s ease-in-out;
}

View File

@@ -1,347 +1,367 @@
const { addBaseUrlToImages } = require('../utils/imageHelper');
const Header = require('../models/header');
const Menu = require('../models/menuHeader');
const Header = require("../models/header");
const HeaderMenu = require("../models/HeaderMenu");
// Helper function để thêm title và url cho programmes
const addProgrammeDetails = (programmes, menuUrl) => {
return programmes.map(prog => ({
...prog,
title: prog.name,
url: `${menuUrl}${prog.code}/`
}));
};
/**
* Helper function to build a tree structure (Mirroring logic in headerMenuController)
*/
const buildTree = (items, parentId = null) => {
const branch = [];
const children = items.filter(item =>
String(item.parentId) === String(parentId) || (item.parentId === null && parentId === null)
);
// Helper function để xử lý menu tree cho API (đơn giản hóa, map menuid thành id)
const processMenuTreeForAPI = (menuTree) => {
return menuTree.map(item => {
const processedItem = {
id: item.menuid, // Map menuid to id for frontend
title: item.title,
url: item.url,
order: item.order,
parent: item.parent || null,
type: item.type,
children: []
};
// Đệ quy cho children
if (item.children && item.children.length > 0) {
processedItem.children = processMenuTreeForAPI(item.children);
for (const child of children) {
const item = child.toObject ? child.toObject() : { ...child };
const subChildren = buildTree(items, item._id);
item.children = subChildren.length > 0 ? subChildren : [];
branch.push(item);
}
return processedItem;
});
return branch.sort((a, b) => a.order - b.order);
};
// Helper function để xử lý menu tree và thêm programme details (cho admin)
const processMenuTree = (menuTree) => {
return menuTree.map(item => {
const processedItem = { ...item };
// Nếu có programmes, thêm title và url
if (item.programmes && item.programmes.length > 0) {
processedItem.programmes = addProgrammeDetails(item.programmes, item.url);
}
// Đệ quy cho children
if (item.children && item.children.length > 0) {
processedItem.children = processMenuTree(item.children);
}
return processedItem;
});
};
// Get header data from MongoDB
const getHeaderData = async () => {
const header = await Header.findOne({ name: 'default' });
if (!header) {
return {
topbar: {
contactInfo: {
phone: '',
email: ''
},
links: []
},
mainMenu: [],
logo: ''
};
}
// Convert to plain object to allow modifications
const headerData = header.toObject();
// Lấy menu tree từ collection menuHeader (đơn giản, không có programmes)
try {
const menuTree = await Menu.getMenuTree();
// Xử lý menu tree để map menuid thành id cho frontend
headerData.mainMenu = processMenuTreeForAPI(menuTree);
} catch (error) {
console.error('Error getting menu tree:', error);
headerData.mainMenu = [];
}
return headerData;
};
// API to get header data
exports.api = async (req, res) => {
try {
// Lấy header data
const header = await getHeaderData();
// Xử lý URL cho logo và các hình ảnh khác
const processedData = addBaseUrlToImages(header);
res.json(processedData);
} catch (err) {
console.error('API Error:', err);
res.status(500).json({ error: 'Error loading header data' });
}
};
// API để lấy menu tree cho frontend (public API)
exports.getMenuTreeAPI = async (req, res) => {
try {
const menuTree = await Menu.getMenuTree();
// Xử lý menu tree để map menuid thành id cho frontend
const processedMenuTree = processMenuTreeForAPI(menuTree);
res.json(processedMenuTree);
} catch (error) {
console.error('Error getting menu tree:', error);
res.status(500).json({ error: 'Error loading menu tree' });
}
};
// API để lấy menu tree (cho admin)
exports.getMenuTree = async (req, res) => {
try {
const menuTree = await Menu.getMenuTree();
res.json(menuTree);
} catch (error) {
console.error('Error getting menu tree:', error);
res.status(500).json({ error: 'Error loading menu tree' });
}
};
// API để lấy programmes theo menu ID
exports.getProgrammesByMenuId = async (req, res) => {
try {
const { menuId } = req.params;
const programmes = await Header.getProgrammesByMenuId(menuId);
// Lấy menu item để có URL
const menuItem = await Menu.findOne({ menuid: menuId });
if (menuItem) {
const programmesWithDetails = addProgrammeDetails(programmes, menuItem.url);
res.json(programmesWithDetails);
} else {
res.json(programmes);
}
} catch (error) {
console.error('Error getting programmes by menu ID:', error);
res.status(500).json({ error: 'Error loading programmes' });
}
};
// API để lấy toàn bộ header data
exports.getHeaderData = async (req, res) => {
try {
const headerData = await getHeaderData();
res.json(headerData);
} catch (error) {
console.error('Error getting header data:', error);
res.status(500).json({ error: 'Error loading header data' });
}
};
// API để lấy menu item theo ID
exports.getMenuItem = async (req, res) => {
try {
const { menuId } = req.params;
const Menu = require('../models/menuHeader');
const menuItem = await Menu.findOne({ menuid: menuId });
if (!menuItem) {
return res.status(404).json({ error: 'Menu item not found' });
}
// Nếu là level type và có fetch = true, lấy programmes
if (menuItem.type === 'level' && menuItem.fetch) {
const programmes = await Menu.getProgrammesByMenuId(menuItem.menuid);
menuItem.programmes = addProgrammeDetails(programmes, menuItem.url);
}
res.json(menuItem);
} catch (error) {
console.error('Error getting menu item:', error);
res.status(500).json({ error: 'Error loading menu item' });
}
};
// Render admin view
// Admin: Render header management page
exports.index = async (req, res) => {
try {
const data = await getHeaderData();
try {
const header = await Header.findOne().sort({ order: 1 });
res.render('admin/header/index', {
title: 'Header Management',
data
});
} catch (error) {
console.error('Error in header index:', error);
req.flash('error_msg', 'An error occurred while loading the page');
res.redirect('/admin/dashboard');
}
};
// Update header (chỉ cập nhật topbar và logo)
exports.update = async (req, res) => {
try {
const headerData = req.body;
// Tìm và cập nhật header
const header = await Header.findOne({ name: 'default' });
if (!header) {
req.flash('error_msg', 'Header not found');
return res.redirect('/admin/header');
}
// Cập nhật từng phần
if (headerData.topbarJson) {
header.topbar = JSON.parse(headerData.topbarJson);
}
if (headerData.logo) {
header.logo = headerData.logo;
}
header.updatedAt = new Date();
await header.save();
// Process menu updates if any
let menuUpdateCount = 0;
let menuErrorCount = 0;
if (headerData.menuUpdates) {
try {
const updates = JSON.parse(headerData.menuUpdates);
if (Array.isArray(updates) && updates.length > 0) {
const Menu = require('../models/menuHeader');
for (const update of updates) {
try {
const { menuid, title, order, type, parent, fetch, isActive } = update;
const updateData = {
title: title,
order: order,
type: type,
parent: parent
// 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 || "",
}
: {
topbar: {
contactInfo: {
phone: "",
email: "",
location: "",
},
socialLinks: [],
},
logo: "",
};
// Add fetch field if provided
if (fetch !== undefined) {
updateData.fetch = fetch;
}
const activeTab = req.query.tab || "topbar";
// Add isActive field if provided
if (isActive !== undefined) {
updateData.isActive = isActive;
}
// Always fetch menu items to ensure they are available even if the user
// switches tabs client-side
const items = await HeaderMenu.find().sort({ order: 1 });
const menuData = {
flat: items,
tree: buildTree(items)
};
const result = await Menu.findOneAndUpdate(
{ menuid: menuid },
updateData,
{ new: true }
);
res.render("admin/header/index", {
layout: "layouts/main",
title: "Header Management",
user: req.session.user || null,
data: data,
activeTab: activeTab,
menuData: menuData
});
} catch (error) {
console.error("Error loading header management:", error);
res.status(500).render("page/error", {
title: "Error",
message: "Failed to load header management page",
});
}
};
if (result) {
menuUpdateCount++;
} else {
console.warn(`Menu item not found for update: ${menuid}`);
menuErrorCount++;
}
} catch (innerErr) {
console.error(`Error updating menu item ${update.menuid}:`, innerErr);
menuErrorCount++;
}
}
// Admin: Get all headers (API)
exports.getAll = async (req, res) => {
try {
const headers = await Header.find().sort({ order: 1 });
res.json({
success: true,
data: headers,
});
} catch (error) {
res.status(500).json({
success: false,
message: error.message,
});
}
};
// Admin: Get single header
exports.show = async (req, res) => {
try {
const header = await Header.findById(req.params.id);
if (!header) {
return res.status(404).json({
success: false,
message: "Header not found",
});
}
} catch (err) {
console.error('Error processing menu updates:', err);
}
res.json({
success: true,
data: header,
});
} catch (error) {
res.status(500).json({
success: false,
message: error.message,
});
}
let flashMsg = 'Header updated successfully.';
if (menuUpdateCount > 0) {
flashMsg += ` Updated ${menuUpdateCount} menu items.`;
}
if (menuErrorCount > 0) {
req.flash('error_msg', `Updated ${menuUpdateCount} items, but failed to update ${menuErrorCount} items. Check logs.`);
} else {
req.flash('success_msg', flashMsg);
}
// Redirect back to the active tab
const activeTab = req.body.activeTab || 'topbar';
res.redirect(`/admin/header?activeTab=${activeTab}`);
} catch (error) {
console.error('Error updating header:', error);
req.flash('error_msg', 'Error updating header: ' + error.message);
res.redirect('/admin/header');
}
};
// Update menu structure (order and parent)
exports.updateMenu = async (req, res) => {
try {
const { updates } = req.body;
// Admin: Create header
exports.store = async (req, res) => {
try {
const { top, offcanvas, menu, logo, ctaButton, status, order } = req.body;
if (!updates || !Array.isArray(updates)) {
return res.status(400).json({
success: false,
error: 'Invalid updates data'
});
const header = new Header({
top,
offcanvas,
menu,
logo,
ctaButton,
status: status || "active",
order: order || 1,
});
await header.save();
res.status(201).json({
success: true,
message: "Header created successfully",
data: header,
});
} catch (error) {
res.status(400).json({
success: false,
message: error.message,
});
}
};
// Admin: Update header
exports.update = async (req, res) => {
try {
let { top, topbarJson, offcanvas, menu, logo, 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 || [],
};
console.log("✓ Converted to top object:", top);
} catch (e) {
console.error("✗ Error parsing topbarJson:", e.message);
return res.status(400).json({
success: false,
message: "Invalid JSON in topbarJson: " + e.message,
});
}
}
// 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 = {};
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 || "",
};
}
const updateData = {
top,
offcanvas,
menu,
ctaButton,
status,
order,
};
if (logo) {
updateData.logo = logoData;
}
console.log("Preparing to update header with data:", JSON.stringify(updateData, null, 2));
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",
});
}
console.log("✓ Header updated successfully:", updatedHeader._id);
console.log("Updated header data:", JSON.stringify(updatedHeader, null, 2));
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,
});
}
};
// Admin: Update status
exports.updateStatus = async (req, res) => {
try {
const { status } = req.body;
if (!["active", "inactive"].includes(status)) {
return res.status(400).json({
success: false,
message: "Invalid status",
});
}
const header = await Header.findByIdAndUpdate(req.params.id, { status }, { new: true });
if (!header) {
return res.status(404).json({
success: false,
message: "Header not found",
});
}
res.json({
success: true,
message: "Header status updated",
data: header,
});
} catch (error) {
res.status(500).json({
success: false,
message: error.message,
});
}
};
// Admin: Delete header
exports.destroy = async (req, res) => {
try {
const header = await Header.findByIdAndDelete(req.params.id);
if (!header) {
return res.status(404).json({
success: false,
message: "Header not found",
});
}
res.json({
success: true,
message: "Header deleted successfully",
});
} catch (error) {
res.status(500).json({
success: false,
message: error.message,
});
}
};
// Public API: Get active header
exports.api = async (req, res) => {
try {
const header = await Header.findOne({ status: "active" }).sort({ order: 1 });
if (!header) {
return res.status(404).json({
success: false,
message: "No active header found",
});
}
res.json({
success: true,
data: header,
});
} catch (error) {
res.status(500).json({
success: false,
message: error.message,
});
}
};
// 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,
});
}
const Menu = require('../models/menuHeader');
// Update each menu item
for (const update of updates) {
const { menuid, title, order, type, parent, fetch, isActive } = update;
console.log(menuid, title, order, type, parent, fetch, isActive);
const updateData = {
title: title,
order: order,
type: type,
parent: parent
};
// Add fetch field if provided (for level type menus)
if (fetch !== undefined) {
updateData.fetch = fetch;
}
// Add isActive field if provided
if (isActive !== undefined) {
updateData.isActive = isActive;
}
await Menu.findOneAndUpdate(
{ menuid: menuid },
updateData,
{ new: true }
);
}
res.json({ success: true });
} catch (error) {
console.error('Error updating menu structure:', error);
req.flash('error_msg', 'Error updating menu structure: ' + error.message);
res.redirect('/admin/header');
}
};

View File

@@ -0,0 +1,175 @@
const HeaderMenu = require("../models/HeaderMenu");
const slugify = require("slugify");
/**
* Helper: Build tree structure from flat array
*/
const buildMenuTree = (items, parentId = null, isPublic = false) => {
const branch = [];
const children = items.filter(item =>
String(item.parentId) === String(parentId) || (item.parentId === null && parentId === null)
);
for (const child of children) {
const item = child.toObject ? child.toObject() : { ...child };
// Clean data for public API if requested
let cleanItem = item;
if (isPublic) {
cleanItem = {
id: item._id,
title: item.title,
url: item.url,
type: item.type
};
}
const subChildren = buildMenuTree(items, item._id, isPublic);
cleanItem.children = subChildren.length > 0 ? subChildren : [];
branch.push(cleanItem);
}
return branch.sort((a, b) => a.order - b.order);
};
/**
* Helper: Recursive delete children
*/
const deleteRecursive = async (parentId) => {
const children = await HeaderMenu.find({ parentId });
for (const child of children) {
await deleteRecursive(child._id);
await HeaderMenu.findByIdAndDelete(child._id);
}
};
// 1. Render Menu Tab logic (called from Header index or directly if route allows)
exports.renderMenuTab = async (req, res) => {
try {
const items = await HeaderMenu.find().sort({ order: 1 });
const menuTree = buildMenuTree(items);
return { menuTree, flatItems: items };
} catch (error) {
console.error("Error fetching menu items:", error);
throw error;
}
};
// 2. Create Menu Item
exports.createMenu = async (req, res) => {
try {
console.log('=== BACKEND: createMenu hit ===');
console.log('Body:', req.body);
const { title, url, parentId, order, status, type } = req.body;
const slug = slugify(title, { lower: true, strict: true });
const newItem = new HeaderMenu({
title,
slug,
url,
parentId: parentId || null,
order: order || 0,
status: status || "active",
type: type || "internal"
});
const savedItem = await newItem.save();
console.log('=== MENU CREATED ===', savedItem);
req.flash("success_msg", "Menu item created successfully");
res.redirect("/admin/header?tab=menu");
} catch (error) {
console.error('=== CREATE MENU ERROR ===', error);
req.flash("error_msg", "Failed to create menu item: " + error.message);
res.redirect("/admin/header?tab=menu");
}
};
// 3. Update Menu Item
exports.updateMenu = async (req, res) => {
try {
const { id } = req.params;
console.log('=== BACKEND: updateMenu hit ===', { id });
console.log('Body:', req.body);
const { title, url, parentId, order, status, type } = req.body;
const updateData = {
url,
parentId: parentId || null,
order,
status,
type
};
if (title) {
updateData.title = title;
updateData.slug = slugify(title, { lower: true, strict: true });
}
const updated = await HeaderMenu.findByIdAndUpdate(id, updateData, { new: true });
if (!updated) {
console.log('=== UPDATE MENU NOT FOUND ===', id);
req.flash("error_msg", "Menu item not found");
} else {
console.log('=== MENU UPDATED ===', updated);
req.flash("success_msg", "Menu item updated successfully");
}
res.redirect("/admin/header?tab=menu");
} catch (error) {
console.error('=== UPDATE MENU ERROR ===', error);
req.flash("error_msg", "Update failed: " + error.message);
res.redirect("/admin/header?tab=menu");
}
};
// 4. Delete Menu Item (Cascade delete children)
exports.deleteMenu = async (req, res) => {
try {
const { id } = req.body;
const menuId = id || req.params.id;
console.log('=== BACKEND: deleteMenu hit ===', { menuId, body: req.body });
await deleteRecursive(menuId);
await HeaderMenu.findByIdAndDelete(menuId);
console.log('=== MENU DELETED ===', menuId);
req.flash("success_msg", "Menu item and its sub-menu deleted successfully");
res.redirect("/admin/header?tab=menu");
} catch (error) {
console.error('=== DELETE MENU ERROR ===', error);
req.flash("error_msg", "Delete failed: " + error.message);
res.redirect("/admin/header?tab=menu");
}
};
// 5. Reorder Menu
exports.reorderMenu = async (req, res) => {
try {
const { items } = req.body; // Array of { id, order, parentId }
if (items && Array.isArray(items)) {
const bulkOps = items.map(item => ({
updateOne: {
filter: { _id: item.id },
update: { order: item.order, parentId: item.parentId || null }
}
}));
await HeaderMenu.bulkWrite(bulkOps);
return res.json({ success: true, message: "Reordered successfully" });
}
res.status(400).json({ success: false, message: "Invalid data" });
} catch (error) {
res.status(500).json({ success: false, message: error.message });
}
};
// 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 tree = buildMenuTree(items, null, true);
res.json({ success: true, data: tree });
} catch (error) {
res.status(500).json({ success: false, message: error.message });
}
};

View File

@@ -0,0 +1,321 @@
const Header = require("../models/header");
// Get all social links
exports.getAll = async (req, res) => {
try {
const header = await Header.findOne({ status: "active" }).sort({ order: 1 });
if (!header) {
return res.status(404).json({
success: false,
message: "No active header found",
});
}
res.json({
success: true,
data: header.top?.socialLinks || [],
});
} catch (error) {
res.status(500).json({
success: false,
message: error.message,
});
}
};
// Get single social link by platform
exports.show = async (req, res) => {
try {
const { platform } = req.params;
const header = await Header.findOne({ status: "active" }).sort({ order: 1 });
if (!header) {
return res.status(404).json({
success: false,
message: "No active header found",
});
}
const socialLink = header.top?.socialLinks?.find((link) => link.platform === platform);
if (!socialLink) {
return res.status(404).json({
success: false,
message: "Social link not found",
});
}
res.json({
success: true,
data: socialLink,
});
} catch (error) {
res.status(500).json({
success: false,
message: error.message,
});
}
};
// Create social link
exports.store = async (req, res) => {
try {
let { platform, url, icon } = req.body;
// Convert platform to lowercase
platform = platform.toLowerCase().trim();
url = url.trim();
icon = icon ? icon.trim() : null;
console.log("Creating social link:", { platform, url, icon });
// Validate required fields
if (!platform || !url) {
console.log("Validation failed: platform or url missing");
return res.status(400).json({
success: false,
message: "Platform and URL are required",
});
}
// Validate platform is in enum
const validPlatforms = ["linkedin", "twitter", "instagram", "youtube", "facebook"];
if (!validPlatforms.includes(platform)) {
console.log("Invalid platform:", platform);
return res.status(400).json({
success: false,
message: `Invalid platform. Must be one of: ${validPlatforms.join(", ")}`,
});
}
// Find header
let header = await Header.findOne({ status: "active" }).sort({ order: 1 });
if (!header) {
console.log("No active header found");
return res.status(404).json({
success: false,
message: "No active header found",
});
}
console.log("Found header:", header._id);
// Check if platform already exists
const existingLink = header.top?.socialLinks?.find((link) => link.platform === platform);
if (existingLink) {
console.log("Platform already exists:", platform);
return res.status(400).json({
success: false,
message: `Social link for ${platform} already exists`,
});
}
// Add new social link
if (!header.top) {
header.top = {};
}
if (!header.top.socialLinks) {
header.top.socialLinks = [];
}
// Calculate next order number
const maxOrder =
header.top.socialLinks.length > 0 ? Math.max(...header.top.socialLinks.map((link) => link.order || 0)) : 0;
header.top.socialLinks.push({
platform,
url,
icon: icon || `fa-brands fa-${platform}`,
order: maxOrder + 1,
});
console.log("Saving header with new social link");
await header.save();
console.log("Social link created successfully");
res.status(201).json({
success: true,
message: "Social link created successfully",
data: header.top.socialLinks[header.top.socialLinks.length - 1],
});
} catch (error) {
console.error("Error creating social link:", error);
res.status(400).json({
success: false,
message: error.message,
});
}
};
// Update social link
exports.update = async (req, res) => {
try {
let { platform } = req.params;
let { url, icon } = req.body;
// Convert to lowercase
platform = platform.toLowerCase().trim();
url = url.trim();
icon = icon ? icon.trim() : null;
// Validate required fields
if (!url) {
return res.status(400).json({
success: false,
message: "URL is required",
});
}
// Find header
const header = await Header.findOne({ status: "active" }).sort({ order: 1 });
if (!header) {
return res.status(404).json({
success: false,
message: "No active header found",
});
}
// Find and update social link
const socialLink = header.top?.socialLinks?.find((link) => link.platform === platform);
if (!socialLink) {
return res.status(404).json({
success: false,
message: "Social link not found",
});
}
socialLink.url = url;
if (icon) {
socialLink.icon = icon;
}
await header.save();
res.json({
success: true,
message: "Social link updated successfully",
data: socialLink,
});
} catch (error) {
res.status(400).json({
success: false,
message: error.message,
});
}
};
// Delete social link
exports.destroy = async (req, res) => {
try {
let { platform } = req.params;
// Convert to lowercase
platform = platform.toLowerCase().trim();
console.log("Deleting social link:", platform);
// Find header
const header = await Header.findOne({ status: "active" }).sort({ order: 1 });
if (!header) {
console.log("No active header found");
return res.status(404).json({
success: false,
message: "No active header found",
});
}
// Find and remove social link
const index = header.top?.socialLinks?.findIndex((link) => link.platform === platform);
if (index === -1 || index === undefined) {
console.log("Social link not found:", platform);
return res.status(404).json({
success: false,
message: "Social link not found",
});
}
const deletedLink = header.top.socialLinks.splice(index, 1);
console.log("Saving header after delete");
await header.save();
console.log("Social link deleted successfully");
res.json({
success: true,
message: "Social link deleted successfully",
data: deletedLink[0],
});
} catch (error) {
console.error("Error deleting social link:", error);
res.status(500).json({
success: false,
message: error.message,
});
}
};
// Bulk update social links (used for reordering and batch updates)
exports.bulkUpdate = async (req, res) => {
try {
const { socialLinks } = req.body;
if (!Array.isArray(socialLinks)) {
return res.status(400).json({
success: false,
message: "socialLinks must be an array",
});
}
// Find header
let header = await Header.findOne({ status: "active" }).sort({ order: 1 });
if (!header) {
return res.status(404).json({
success: false,
message: "No active header found",
});
}
// Validate all social links
for (const link of socialLinks) {
if (!link.platform || !link.url) {
return res.status(400).json({
success: false,
message: "Each social link must have platform and url",
});
}
}
// Update social links with order field
if (!header.top) {
header.top = {};
}
header.top.socialLinks = socialLinks.map((link, index) => ({
platform: link.platform,
url: link.url,
icon: link.icon || `fa-brands fa-${link.platform}`,
order: link.order || index + 1, // Use provided order or calculate from index
}));
await header.save();
res.json({
success: true,
message: "Social links updated successfully",
data: header.top.socialLinks,
});
} catch (error) {
res.status(400).json({
success: false,
message: error.message,
});
}
};

184
data/header-menu.json Normal file
View File

@@ -0,0 +1,184 @@
[
{
"label": "Home",
"slug": "home",
"href": "/",
"type": "internal",
"order": 1,
"isActive": true,
"children": []
},
{
"label": "About Us",
"slug": "about-us",
"href": "/about",
"type": "internal",
"order": 2,
"isActive": true,
"children": []
},
{
"label": "Pages",
"slug": "pages",
"href": "#",
"type": "internal",
"order": 3,
"isActive": true,
"children": [
{
"label": "Services",
"slug": "services",
"href": "/services",
"type": "internal",
"order": 1,
"isActive": true,
"children": [
{
"label": "Service List",
"slug": "service-list",
"href": "/service",
"type": "internal",
"order": 1,
"isActive": true
},
{
"label": "Service Details",
"slug": "service-details",
"href": "/service-details",
"type": "internal",
"order": 2,
"isActive": true
}
]
},
{
"label": "Country List",
"slug": "country-list",
"href": "/country-list",
"type": "internal",
"order": 2,
"isActive": true,
"children": [
{
"label": "Country List",
"slug": "country-list-all",
"href": "/country-list",
"type": "internal",
"order": 1,
"isActive": true
},
{
"label": "Country Details",
"slug": "country-details",
"href": "/country-details",
"type": "internal",
"order": 2,
"isActive": true
}
]
},
{
"label": "Our Pricing",
"slug": "pricing",
"href": "/pricing",
"type": "internal",
"order": 3,
"isActive": true
},
{
"label": "Appointment",
"slug": "appointment",
"href": "/appointment",
"type": "internal",
"order": 4,
"isActive": true
},
{
"label": "FAQ",
"slug": "faq",
"href": "/faq",
"type": "internal",
"order": 5,
"isActive": true
}
]
},
{
"label": "VISA",
"slug": "visa",
"href": "#",
"type": "internal",
"order": 4,
"isActive": true,
"children": [
{
"label": "Visa List",
"slug": "visa-list",
"href": "/visa-list",
"type": "internal",
"order": 1,
"isActive": true
},
{
"label": "Visa Details",
"slug": "visa-details",
"href": "/visa-details",
"type": "internal",
"order": 2,
"isActive": true
}
]
},
{
"label": "Blog",
"slug": "blog",
"href": "#",
"type": "internal",
"order": 5,
"isActive": true,
"children": [
{
"label": "Blog Grid",
"slug": "blog-grid",
"href": "/blog-grid",
"type": "internal",
"order": 1,
"isActive": true
},
{
"label": "Blog Standard",
"slug": "blog-standard",
"href": "/blog",
"type": "internal",
"order": 2,
"isActive": true
},
{
"label": "Blog Details",
"slug": "blog-details",
"href": "/blog-details",
"type": "internal",
"order": 3,
"isActive": true
}
]
},
{
"label": "Contact Us",
"slug": "contact-us",
"href": "/contact",
"type": "internal",
"order": 6,
"isActive": true,
"children": []
},
{
"label": "External Portal",
"slug": "external-portal",
"href": "https://partner.hailearning.edu.vn",
"type": "external",
"order": 7,
"isActive": false,
"children": []
}
]

View File

@@ -1,24 +1,52 @@
{
"topbar": {
"contactInfo": {
"phone": "+(123)-456-789",
"email": "info@ggcamp.com"
"top": {
"phone": "+09 378 357 5222",
"email": "info@hailearning.edu.vn",
"location": "69 Street, 5th Avenue LA, United States",
"socialLinks": [
{
"platform": "linkedin",
"url": "https://linkedin.com",
"icon": "fa-brands fa-linkedin"
},
{
"platform": "twitter",
"url": "https://twitter.com",
"icon": "fa-brands fa-twitter"
},
{
"platform": "instagram",
"url": "https://instagram.com",
"icon": "fa-brands fa-instagram"
},
{
"platform": "youtube",
"url": "https://youtube.com",
"icon": "fa-brands fa-youtube"
}
],
"languages": [
{
"name": "English",
"value": "1"
},
{
"name": "Bangla",
"value": "2"
},
{
"name": "Hindi",
"value": "3"
}
]
},
"links": [
{
"text": "Instagram",
"url": "https://instagram.com"
},
{
"text": "Facebook",
"url": "https://facebook.com"
},
{
"text": "Dribbble",
"url": "https://dribbble.com"
}
]
},
"logo": "/templates/yootheme/cache/c9/logo-camp-adventure-c9850ee6.png"
"offcanvas": {
"description": "Nullam dignissim, ante scelerisque the is euismod fermentum odio sem semper the is erat, a feugiat leo urna eget eros. Duis Aenean a imperdiet risus.",
"contactInfo": {
"address": "Main Street, Melbourne, Australia",
"email": "info@hailearning.edu.vn",
"workingHours": "Mod-Friday, 09am - 05pm",
"phone": "+09 378 357 5222"
}
}
}

48
models/HeaderMenu.js Normal file
View File

@@ -0,0 +1,48 @@
const mongoose = require('mongoose');
const HeaderMenuSchema = new mongoose.Schema({
title: {
type: String,
required: true,
trim: true
},
slug: {
type: String,
required: true,
trim: true,
lowercase: true
},
url: {
type: String,
required: true,
trim: true
},
parentId: {
type: mongoose.Schema.Types.ObjectId,
ref: 'HeaderMenu',
default: null
},
order: {
type: Number,
default: 0
},
status: {
type: String,
enum: ['active', 'inactive'],
default: 'active'
},
type: {
type: String,
enum: ['internal', 'external'],
default: 'internal'
}
}, {
timestamps: true
});
// Indexes for optimization
HeaderMenuSchema.index({ order: 1 });
HeaderMenuSchema.index({ status: 1 });
HeaderMenuSchema.index({ parentId: 1, order: 1 }); // Useful for fetching children in order
module.exports = mongoose.model('HeaderMenu', HeaderMenuSchema);

View File

@@ -1,129 +1,115 @@
const mongoose = require('mongoose');
const mongoose = require("mongoose");
// Schema cho các link trong topbar
const topbarLinkSchema = new mongoose.Schema({
text: {
type: String,
required: true,
trim: true
},
url: {
type: String,
required: true,
trim: true
}
}, { _id: false });
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 },
);
// Schema cho contact info trong topbar
const contactInfoSchema = new mongoose.Schema({
phone: {
type: String,
required: true,
trim: true
},
email: {
type: String,
required: true,
trim: true
}
}, { _id: false });
const languageSchema = new mongoose.Schema(
{
name: {
type: String,
required: true,
},
value: {
type: String,
required: true,
},
},
{ _id: false },
);
// Schema cho topbar
const topbarSchema = new mongoose.Schema({
contactInfo: {
type: contactInfoSchema,
required: true
},
links: {
type: [topbarLinkSchema],
default: []
}
}, { _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 },
);
// Main Header Schema
const headerSchema = new mongoose.Schema({
name: {
type: String,
default: 'default',
unique: true
},
topbar: {
type: topbarSchema,
required: true
},
logo: {
type: String,
required: true,
trim: true
}
}, {
timestamps: true
});
const headerSchema = new mongoose.Schema(
{
// Top bar
top: {
phone: String,
email: String,
location: String,
socialLinks: [socialLinkSchema],
languages: [languageSchema],
},
// Method để lấy menu tree từ collection menuHeader
headerSchema.statics.getMenuTree = async function() {
try {
const Menu = require('./menuHeader');
return await Menu.getMenuTree();
} catch (error) {
console.error('Error getting menu tree:', error);
throw error;
}
};
// Offcanvas
offcanvas: {
description: String,
contactInfo: {
address: String,
email: String,
workingHours: String,
phone: String,
},
},
// Method để lấy menu tree với programmes từ collection menuHeader
headerSchema.statics.getMenuTreeWithProgrammes = async function() {
try {
const Menu = require('./menuHeader');
return await Menu.getMenuTreeWithProgrammes();
} catch (error) {
console.error('Error getting menu tree with programmes:', error);
throw error;
}
};
// Menu
menu: [menuItemSchema],
// Method để lấy programmes theo menu ID
headerSchema.statics.getProgrammesByMenuId = async function(menuId) {
try {
const Menu = require('./menuHeader');
return await Menu.getProgrammesByMenuId(menuId);
} catch (error) {
console.error('Error getting programmes by menu ID:', error);
throw error;
}
};
// Logo
logo: {
light: String,
dark: String,
alt: String,
},
// Tạo migration script để import dữ liệu từ menu.json
headerSchema.statics.migrateFromJson = async function(jsonData) {
try {
// Kiểm tra xem đã có header mặc định chưa
const existingHeader = await this.findOne({ name: 'default' });
// CTA Button
ctaButton: {
label: String,
href: String,
style: {
type: String,
enum: ["primary", "secondary", "outline"],
default: "primary",
},
},
// Chỉ giữ lại topbar và logo, bỏ search và mainMenu
const headerData = {
topbar: jsonData.topbar,
logo: jsonData.logo
};
// Status
status: {
type: String,
enum: ["active", "inactive"],
default: "active",
},
if (existingHeader) {
// Cập nhật header hiện có
Object.assign(existingHeader, headerData);
await existingHeader.save();
console.log('Header data updated successfully');
return existingHeader;
} else {
// Tạo header mới với dữ liệu từ JSON
const newHeader = await this.create({
name: 'default',
...headerData
});
console.log('Header data imported successfully');
return newHeader;
}
} catch (error) {
console.error('Error migrating header data:', error);
throw error;
}
};
order: {
type: Number,
default: 1,
},
},
{ timestamps: true },
);
module.exports = mongoose.model('Header', headerSchema);
module.exports = mongoose.model("Header", headerSchema);

Binary file not shown.

After

Width:  |  Height:  |  Size: 331 KiB

BIN
public/img/Logo_round.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 331 KiB

31
public/js/custom-modal.js Normal file
View File

@@ -0,0 +1,31 @@
/**
* Custom Modal Utilities
*/
// Global function to clean up any stuck modal backdrops
window.forceCleanupModals = function() {
document.body.classList.remove('modal-open');
document.body.style.overflow = '';
document.body.style.paddingRight = '';
const backdrops = document.querySelectorAll('.modal-backdrop, .overlay, .loading');
if (backdrops.length > 0) {
console.warn('Cleanup: Removing backdrops:', backdrops.length);
backdrops.forEach(el => el.remove());
}
};
// Auto-cleanup on hide
document.addEventListener('hidden.bs.modal', () => {
setTimeout(forceCleanupModals, 100);
});
// Watchdog
setInterval(() => {
if (document.querySelectorAll('.modal.show').length === 0) {
if (document.querySelectorAll('.modal-backdrop').length > 0) {
forceCleanupModals();
}
}
}, 2000);
window.addEventListener('load', forceCleanupModals);

View File

@@ -0,0 +1,27 @@
/**
* Flash Message Handler
* Displays server-side flash messages using showToast
*/
document.addEventListener('DOMContentLoaded', () => {
const flashDataEl = document.getElementById('flash-messages-data');
if (flashDataEl) {
try {
const messages = JSON.parse(flashDataEl.textContent);
if (messages.success_msg) {
showToast('Success', messages.success_msg, 'success');
}
if (messages.error_msg) {
showToast('Error', messages.error_msg, 'error');
}
if (messages.error) {
showToast('Error', messages.error, 'error');
}
} catch (e) {
console.error('Error parsing flash messages data:', e);
}
}
});

56
public/js/main.js Normal file
View File

@@ -0,0 +1,56 @@
/**
* Global Admin Scripts
*/
console.log('CMS Admin Main JS loaded');
// Helper to handle AJAX form submissions with confirmation or loading state
window.handleFormAjax = async (formEl, options = {}) => {
const {
confirmMessage = null,
onSuccess = null,
onError = null,
loaderBtn = null
} = options;
if (confirmMessage && !confirm(confirmMessage)) {
return false;
}
const formData = new FormData(formEl);
const data = Object.fromEntries(formData.entries());
const originalBtnHtml = loaderBtn ? loaderBtn.innerHTML : null;
try {
if (loaderBtn) {
loaderBtn.disabled = true;
loaderBtn.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i>Processing...';
}
const response = await fetch(formEl.action, {
method: formEl.method || 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
});
const result = await response.json();
if (result.success) {
if (onSuccess) onSuccess(result);
else showToast('Success', result.message || 'Operation successful', 'success');
} else {
if (onError) onError(result);
else showToast('Error', result.message || 'Operation failed', 'error');
}
} catch (error) {
console.error('AJAX Error:', error);
showToast('Error', 'A network error occurred', 'error');
} finally {
if (loaderBtn) {
loaderBtn.disabled = false;
loaderBtn.innerHTML = originalBtnHtml;
}
}
};

74
public/js/toast.js Normal file
View File

@@ -0,0 +1,74 @@
/**
* Toast Notification System
* Requires Bootstrap 5 JS
*/
function showToast(title, message, type = 'info') {
// Create container if not exists
let toastContainer = document.getElementById('toast-container');
if (!toastContainer) {
toastContainer = document.createElement('div');
toastContainer.id = 'toast-container';
toastContainer.className = 'toast-container position-fixed top-0 end-0 p-3';
toastContainer.style.zIndex = '1100';
document.body.appendChild(toastContainer);
}
// Inject CSS for transition if not already present
if (!document.getElementById('toast-animation-styles')) {
const style = document.createElement('style');
style.id = 'toast-animation-styles';
style.innerHTML = `
@keyframes slideInRight {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
.toast-slide-in {
animation: slideInRight 0.3s ease-out forwards;
}
`;
document.head.appendChild(style);
}
// Map type to Bootstrap color class
const bgClass = type === 'success' ? 'bg-success' : (type === 'error' || type === 'danger' ? 'bg-danger' : 'bg-info');
// Create toast element
const toastEl = document.createElement('div');
toastEl.className = `toast align-items-center text-white ${bgClass} border-0 mb-2 toast-slide-in`;
toastEl.setAttribute('role', 'alert');
toastEl.setAttribute('aria-live', 'assertive');
toastEl.setAttribute('aria-atomic', 'true');
toastEl.innerHTML = `
<div class="d-flex">
<div class="toast-body">
<div class="d-flex align-items-center">
<i class="fas ${type === 'success' ? 'fa-check-circle' : (type === 'error' ? 'fa-exclamation-circle' : 'fa-info-circle')} me-2"></i>
<div>
<strong class="me-auto">${title}</strong><br>
${message}
</div>
</div>
</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>
</div>
`;
toastContainer.appendChild(toastEl);
// Initialize and show
const bsToast = new bootstrap.Toast(toastEl, { autohide: true, delay: 5000 });
bsToast.show();
// Remove from DOM when hidden
toastEl.addEventListener('hidden.bs.toast', () => {
toastEl.remove();
});
}
// Map showNotification to showToast for backward compatibility
window.showNotification = function(message, type) {
const title = type === 'success' ? 'Success' : (type === 'error' ? 'Error' : 'Notification');
showToast(title, message, type);
};

View File

@@ -0,0 +1,18 @@
<svg width="159" height="48" viewBox="0 0 159 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M41.3036 18.9806C41.604 18.9074 41.9089 18.8663 42.2184 18.8572C42.5279 18.8389 42.8237 18.8297 43.1059 18.8297C43.3699 18.8297 43.6839 18.8526 44.048 18.8983C44.4213 18.9349 44.7489 19.0034 45.0311 19.104L45.195 19.3372L40.0337 37.7143C39.5513 37.7692 39.0142 37.8057 38.4226 37.824C37.8309 37.8514 37.2665 37.8652 36.7295 37.8652C35.9466 37.8652 35.3322 37.7372 34.8862 37.4812C34.4401 37.2252 34.1124 36.7223 33.9031 35.9726L29.3016 19.3509C29.6749 19.1863 30.0708 19.0446 30.4895 18.9257C30.9174 18.8069 31.3589 18.7474 31.814 18.7474C32.433 18.7474 32.9154 18.912 33.2613 19.2412C33.6072 19.5703 33.8712 20.096 34.0533 20.8183L36.2516 28.9097C36.3972 29.4309 36.5337 29.9749 36.6612 30.5417C36.7886 31.0994 36.907 31.6617 37.0162 32.2286C37.1254 32.7863 37.2256 33.3303 37.3166 33.8606C37.3439 34.0069 37.3757 34.0983 37.4122 34.1349C37.4577 34.1714 37.5214 34.1897 37.6033 34.1897L41.3036 18.9806Z" fill="black"/>
<path d="M48.0401 18.9806C48.3678 18.9166 48.732 18.8754 49.1325 18.8572C49.5421 18.8389 49.8743 18.8297 50.1292 18.8297C50.4114 18.8297 50.7437 18.8389 51.126 18.8572C51.5174 18.8754 51.8815 18.9166 52.2183 18.9806V37.7143C51.8815 37.7783 51.5174 37.8194 51.126 37.8377C50.7437 37.856 50.4114 37.8652 50.1292 37.8652C49.8743 37.8652 49.5421 37.856 49.1325 37.8377C48.732 37.8194 48.3678 37.7783 48.0401 37.7143V18.9806Z" fill="black"/>
<path d="M55.2672 36.0823C55.3218 35.488 55.5039 34.9029 55.8134 34.3269C56.132 33.7509 56.4915 33.2892 56.892 32.9417C57.6658 33.408 58.5396 33.824 59.5136 34.1897C60.4876 34.5554 61.4571 34.7383 62.4219 34.7383C63.2594 34.7383 63.8693 34.5417 64.2516 34.1486C64.6339 33.7463 64.8251 33.3029 64.8251 32.8183C64.8251 32.4709 64.7113 32.1052 64.4837 31.7212C64.2652 31.328 63.7691 30.9714 62.9954 30.6514L59.7048 29.2663C58.576 28.7909 57.643 28.16 56.9057 27.3737C56.1775 26.5874 55.8134 25.5452 55.8134 24.2469C55.8134 23.1223 56.1047 22.1349 56.6872 21.2846C57.2698 20.4343 58.0572 19.7714 59.0494 19.296C60.0507 18.8206 61.1658 18.5829 62.3946 18.5829C63.669 18.5829 64.866 18.7474 65.9857 19.0766C67.1053 19.4057 68.0201 19.7577 68.7301 20.1326C68.6937 20.7269 68.5481 21.3074 68.2932 21.8743C68.0383 22.432 67.7106 22.8983 67.3101 23.2732C66.6092 22.9349 65.799 22.6286 64.8797 22.3543C63.9603 22.08 63.1365 21.9429 62.4083 21.9429C61.5981 21.9429 61.0065 22.1303 60.6333 22.5052C60.2691 22.88 60.0871 23.2777 60.0871 23.6983C60.0871 24.0823 60.2145 24.4206 60.4694 24.7132C60.7334 24.9966 61.1703 25.2663 61.7802 25.5223L65.1254 26.9212C65.9902 27.2777 66.7275 27.7257 67.3374 28.2652C67.9564 28.7954 68.4252 29.4034 68.7438 30.0892C69.0715 30.7657 69.2353 31.5017 69.2353 32.2972C69.2353 33.3943 68.9713 34.3817 68.4434 35.2594C67.9245 36.1372 67.1371 36.832 66.0812 37.344C65.0344 37.856 63.7191 38.112 62.1352 38.112C61.0247 38.112 59.8504 37.9383 58.6125 37.5909C57.3836 37.2343 56.2685 36.7314 55.2672 36.0823Z" fill="black"/>
<path d="M76.7523 18.9806C77.0891 18.9349 77.4896 18.8983 77.9539 18.8709C78.4181 18.8434 78.8232 18.8297 79.1691 18.8297C79.5059 18.8297 79.9064 18.8434 80.3706 18.8709C80.844 18.8983 81.2445 18.944 81.5722 19.008L87.4844 37.44C87.1931 37.6046 86.8199 37.728 86.3648 37.8103C85.9096 37.9017 85.4818 37.9474 85.0813 37.9474C84.4259 37.9474 83.9116 37.7966 83.5384 37.4949C83.1652 37.184 82.8511 36.5806 82.5963 35.6846L80.5072 28.3749C80.2887 27.5977 80.0384 26.7017 79.7562 25.6869C79.4831 24.672 79.2374 23.7212 79.0189 22.8343H78.855C78.7185 23.5749 78.5182 24.4252 78.2543 25.3852C77.9994 26.336 77.7673 27.168 77.5579 27.8812L74.6359 37.7417C74.3811 37.8057 74.1034 37.8469 73.803 37.8652C73.5027 37.8834 73.1932 37.8926 72.8746 37.8926C72.556 37.8926 72.201 37.8697 71.8095 37.824C71.4181 37.7783 71.0995 37.7097 70.8538 37.6183L70.7036 37.3989L76.7523 18.9806ZM76.9981 33.7097C76.8707 33.7097 76.7159 33.7097 76.5339 33.7097C76.3518 33.7006 76.1697 33.696 75.9877 33.696C75.8147 33.6869 75.6645 33.6823 75.5371 33.6823H73.3661L74.6496 30.4046H76.5475C76.6749 30.4046 76.8251 30.4046 76.9981 30.4046C77.171 30.3954 77.344 30.3909 77.5169 30.3909C77.6899 30.3817 77.8355 30.3772 77.9539 30.3772H80.0429C80.1704 30.3772 80.3206 30.3817 80.4935 30.3909C80.6665 30.3909 80.8394 30.3954 81.0124 30.4046C81.1853 30.4046 81.3355 30.4046 81.463 30.4046H83.4292L84.4805 33.6823H82.3232C82.1957 33.6823 82.041 33.6869 81.8589 33.696C81.6769 33.696 81.4948 33.7006 81.3128 33.7097C81.1398 33.7097 80.9896 33.7097 80.8622 33.7097H76.9981Z" fill="black"/>
<path d="M88.069 19.2274C88.3876 19.0903 88.7426 18.976 89.134 18.8846C89.5254 18.7932 89.9077 18.7474 90.2809 18.7474C91.0092 18.7474 91.5371 18.9074 91.8648 19.2274C92.2016 19.5383 92.4383 20.1417 92.5748 21.0377L93.8037 28.4297C93.9038 29.0057 94.004 29.632 94.1041 30.3086C94.2042 30.9852 94.2952 31.6389 94.3772 32.2697C94.4682 32.8914 94.5365 33.4217 94.582 33.8606C94.6002 34.0343 94.6229 34.1394 94.6502 34.176C94.6867 34.2034 94.7504 34.2172 94.8414 34.2172L97.2036 19.3509C97.6132 19.2777 98.0592 19.2274 98.5417 19.2C99.0241 19.1634 99.4656 19.1452 99.8661 19.1452C100.294 19.1452 100.745 19.1634 101.218 19.2C101.7 19.2274 102.137 19.2777 102.529 19.3509L104.713 33.8606C104.741 34.0343 104.772 34.1394 104.809 34.176C104.854 34.2034 104.918 34.2172 105 34.2172C105.055 33.7052 105.127 33.1109 105.218 32.4343C105.31 31.7577 105.41 31.0629 105.519 30.3497C105.628 29.6366 105.733 28.9829 105.833 28.3886L107.458 18.9806C107.767 18.9166 108.086 18.8754 108.414 18.8572C108.741 18.8389 109.046 18.8297 109.328 18.8297C109.647 18.8297 109.984 18.8526 110.339 18.8983C110.694 18.944 111.012 19.0126 111.295 19.104L111.499 19.3509L107.84 37.6869C107.367 37.7417 106.789 37.7829 106.106 37.8103C105.432 37.8469 104.823 37.8652 104.276 37.8652C103.657 37.8652 103.111 37.76 102.638 37.5497C102.165 37.3394 101.864 36.8137 101.737 35.9726L100.754 29.8423C100.59 28.7909 100.43 27.6846 100.276 26.5234C100.13 25.3623 100.012 24.3292 99.9207 23.424H99.7159C99.6067 24.3292 99.4701 25.3623 99.3063 26.5234C99.1424 27.6754 98.9695 28.7817 98.7874 29.8423L97.4766 37.6869C96.976 37.7417 96.4071 37.7829 95.7699 37.8103C95.1418 37.8469 94.5456 37.8652 93.9812 37.8652C93.3167 37.8652 92.7478 37.76 92.2744 37.5497C91.8102 37.3394 91.4962 36.8092 91.3323 35.9589L88.069 19.2274Z" fill="black"/>
<path d="M118.115 18.9806C118.451 18.9349 118.852 18.8983 119.316 18.8709C119.78 18.8434 120.185 18.8297 120.531 18.8297C120.868 18.8297 121.269 18.8434 121.733 18.8709C122.206 18.8983 122.607 18.944 122.934 19.008L128.847 37.44C128.555 37.6046 128.182 37.728 127.727 37.8103C127.272 37.9017 126.844 37.9474 126.444 37.9474C125.788 37.9474 125.274 37.7966 124.901 37.4949C124.527 37.184 124.213 36.5806 123.958 35.6846L121.869 28.3749C121.651 27.5977 121.401 26.7017 121.118 25.6869C120.845 24.672 120.6 23.7212 120.381 22.8343H120.217C120.081 23.5749 119.88 24.4252 119.617 25.3852C119.362 26.336 119.13 27.168 118.92 27.8812L115.998 37.7417C115.743 37.8057 115.466 37.8469 115.165 37.8652C114.865 37.8834 114.555 37.8926 114.237 37.8926C113.918 37.8926 113.563 37.8697 113.172 37.824C112.78 37.7783 112.462 37.7097 112.216 37.6183L112.066 37.3989L118.115 18.9806ZM118.36 33.7097C118.233 33.7097 118.078 33.7097 117.896 33.7097C117.714 33.7006 117.532 33.696 117.35 33.696C117.177 33.6869 117.027 33.6823 116.899 33.6823H114.728L116.012 30.4046H117.91C118.037 30.4046 118.187 30.4046 118.36 30.4046C118.533 30.3954 118.706 30.3909 118.879 30.3909C119.052 30.3817 119.198 30.3772 119.316 30.3772H121.405C121.533 30.3772 121.683 30.3817 121.856 30.3909C122.029 30.3909 122.202 30.3954 122.375 30.4046C122.548 30.4046 122.698 30.4046 122.825 30.4046H124.791L125.843 33.6823H123.685C123.558 33.6823 123.403 33.6869 123.221 33.696C123.039 33.696 122.857 33.7006 122.675 33.7097C122.502 33.7097 122.352 33.7097 122.224 33.7097H118.36Z" fill="black"/>
<path d="M133.41 29.4172H137.588V37.7143C137.251 37.7783 136.892 37.8194 136.51 37.8377C136.136 37.856 135.809 37.8652 135.526 37.8652C135.272 37.8652 134.939 37.856 134.53 37.8377C134.12 37.8194 133.747 37.7783 133.41 37.7143V29.4172ZM138.954 18.9806C139.245 18.9166 139.532 18.8754 139.814 18.8572C140.096 18.8389 140.374 18.8297 140.647 18.8297C141.002 18.8297 141.361 18.8526 141.725 18.8983C142.09 18.944 142.413 19.0172 142.695 19.1177L142.859 19.3509L137.37 32.448H133.683L128.14 19.4743C128.513 19.264 128.922 19.0949 129.369 18.9669C129.815 18.8297 130.256 18.7612 130.693 18.7612C131.285 18.7612 131.74 18.8937 132.058 19.1589C132.377 19.424 132.655 19.8537 132.891 20.448L134.311 23.9863C134.53 24.5532 134.744 25.1612 134.953 25.8103C135.162 26.4594 135.358 27.1406 135.54 27.8537H135.69C135.809 27.4057 135.941 26.9486 136.086 26.4823C136.232 26.016 136.382 25.5589 136.537 25.1109C136.701 24.6629 136.86 24.2423 137.015 23.8492L138.954 18.9806Z" fill="black"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M17.9978 43.3281C20.5208 32.8971 19.5204 21.9194 15.1533 12.1223C23.8314 8.6219 33.1615 7.04718 42.5022 7.50735C42.9362 8.94698 43.3244 10.3982 43.667 11.8579L52.0194 13.536H26.0885V48L24.9804 43.2663C22.6597 43.4062 20.329 43.4278 17.9978 43.3281Z" fill="#E13833"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M17.6248 38.3511C16.4941 29.4258 12.9019 20.9574 7.20915 13.925C14.6893 8.28785 23.296 4.34155 32.437 2.35731C33.2346 3.64812 33.9906 4.96162 34.7036 6.29676C27.8434 6.78015 21.0798 8.33535 14.6725 10.9201L13.4273 11.422L13.9753 12.6516C17.5633 20.7015 18.8148 29.6527 17.6248 38.3511Z" fill="black"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M15.8945 37.5263C12.4621 29.6575 6.98999 22.8108 0 17.7315C5.77315 10.3418 13.0689 4.29249 21.3877 0C22.4075 0.960982 23.3973 1.94993 24.3567 2.9658C17.8737 5.22498 11.7596 8.48472 6.24812 12.6389L4.91621 13.6421L5.9665 14.9398C11.251 21.4667 14.663 29.2764 15.8945 37.5263Z" fill="#E13833"/>
<path d="M106.782 37.571C108.845 36.9354 111.436 36.0416 113.189 35.1346C110.17 36.4323 109.294 34.9161 109.188 34.3865C108.114 35.1346 107.105 35.8563 106.242 36.4786C105.675 36.8891 106.116 37.7763 106.782 37.571Z" fill="black"/>
<path d="M132.938 27.1765L141.27 20.3638C141.27 20.3638 138.6 22.8002 138.534 24.0317C138.237 24.3826 132.938 27.1765 132.938 27.1765Z" fill="black"/>
<path d="M132.964 25.1771C132.964 25.1771 139.048 21.0458 140.683 19.5098C138.646 21.0259 137.908 20.8736 137.38 20.6551C134.23 23.8596 132.964 25.1771 132.964 25.1771Z" fill="black"/>
<path d="M121.106 34.9095C121.106 34.9095 126.84 31.5528 128.442 30.4405C127.862 30.8775 126.893 32.1222 126.834 32.9432L121.106 34.9095Z" fill="black"/>
<path d="M152.133 21.6416C151.724 19.0265 151.328 16.5503 150.926 13.9682C150.676 14.1735 150.471 14.339 150.274 14.5045C148.791 15.7956 147.149 16.8284 145.304 17.5236C144.677 17.7553 144.111 17.7487 143.524 17.3779C142.39 16.6562 141.223 15.9876 140.07 15.2924C140.498 14.7296 140.979 14.5376 141.639 14.6171C142.548 14.723 143.471 14.7495 144.387 14.7561C144.664 14.7561 145.007 14.6568 145.218 14.478C148.428 11.7503 151.618 9.00272 154.809 6.24852C155.685 5.49376 156.7 5.11638 157.847 5.14286C158.843 5.16272 159.258 5.79831 158.836 6.70534C158.619 7.17541 158.263 7.61238 157.88 7.96328C156.944 8.82397 155.969 9.63169 154.993 10.446C154.723 10.6711 154.585 10.9029 154.558 11.2736C154.347 13.9947 154.09 16.7092 153.892 19.4303C153.846 20.0328 153.649 20.4764 153.148 20.814C152.818 21.0325 152.541 21.304 152.133 21.6416Z" fill="black"/>
<path d="M143.32 10.2275C144.064 9.57872 144.585 8.75114 145.785 8.92989C146.991 9.11527 148.224 9.12189 149.45 9.20796C149.549 9.21458 149.654 9.23445 149.858 9.26093C148.949 10.0289 148.118 10.7373 147.274 11.4325C147.195 11.4987 147.037 11.5384 146.938 11.5053C145.818 11.1544 144.704 10.7903 143.59 10.4196C143.504 10.4063 143.445 10.3269 143.32 10.2275Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -0,0 +1,18 @@
<svg width="159" height="48" viewBox="0 0 159 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M41.3036 18.9806C41.604 18.9074 41.9089 18.8663 42.2184 18.8572C42.5279 18.8389 42.8237 18.8297 43.1059 18.8297C43.3699 18.8297 43.6839 18.8526 44.048 18.8983C44.4212 18.9349 44.7489 19.0034 45.0311 19.104L45.195 19.3372L40.0337 37.7143C39.5513 37.7692 39.0142 37.8057 38.4226 37.824C37.8309 37.8514 37.2665 37.8652 36.7295 37.8652C35.9466 37.8652 35.3322 37.7372 34.8862 37.4812C34.4401 37.2252 34.1124 36.7223 33.9031 35.9726L29.3016 19.3509C29.6749 19.1863 30.0708 19.0446 30.4895 18.9257C30.9174 18.8069 31.3589 18.7474 31.814 18.7474C32.433 18.7474 32.9154 18.912 33.2613 19.2411C33.6072 19.5703 33.8712 20.096 34.0533 20.8183L36.2516 28.9097C36.3972 29.4309 36.5337 29.9749 36.6612 30.5417C36.7886 31.0994 36.907 31.6617 37.0162 32.2286C37.1254 32.7863 37.2256 33.3303 37.3166 33.8606C37.3439 34.0069 37.3757 34.0983 37.4122 34.1349C37.4577 34.1714 37.5214 34.1897 37.6033 34.1897L41.3036 18.9806Z" fill="white"/>
<path d="M48.0401 18.9806C48.3678 18.9166 48.732 18.8754 49.1325 18.8572C49.5421 18.8389 49.8743 18.8297 50.1292 18.8297C50.4114 18.8297 50.7437 18.8389 51.126 18.8572C51.5174 18.8754 51.8815 18.9166 52.2183 18.9806V37.7143C51.8815 37.7783 51.5174 37.8194 51.126 37.8377C50.7437 37.856 50.4114 37.8652 50.1292 37.8652C49.8743 37.8652 49.5421 37.856 49.1325 37.8377C48.732 37.8194 48.3678 37.7783 48.0401 37.7143V18.9806Z" fill="white"/>
<path d="M55.2672 36.0823C55.3218 35.488 55.5039 34.9029 55.8134 34.3269C56.132 33.7509 56.4915 33.2892 56.892 32.9417C57.6658 33.408 58.5396 33.824 59.5136 34.1897C60.4876 34.5554 61.4571 34.7383 62.4219 34.7383C63.2594 34.7383 63.8693 34.5417 64.2516 34.1486C64.6339 33.7463 64.8251 33.3029 64.8251 32.8183C64.8251 32.4709 64.7113 32.1052 64.4837 31.7212C64.2652 31.328 63.7691 30.9714 62.9954 30.6514L59.7048 29.2663C58.576 28.7909 57.643 28.16 56.9057 27.3737C56.1775 26.5874 55.8134 25.5452 55.8134 24.2469C55.8134 23.1223 56.1047 22.1349 56.6872 21.2846C57.2698 20.4343 58.0572 19.7714 59.0494 19.296C60.0507 18.8206 61.1658 18.5829 62.3946 18.5829C63.669 18.5829 64.866 18.7474 65.9857 19.0766C67.1053 19.4057 68.0201 19.7577 68.7301 20.1326C68.6937 20.7269 68.5481 21.3074 68.2932 21.8743C68.0383 22.432 67.7106 22.8983 67.3101 23.2732C66.6092 22.9349 65.799 22.6286 64.8797 22.3543C63.9603 22.08 63.1365 21.9429 62.4083 21.9429C61.5981 21.9429 61.0065 22.1303 60.6333 22.5052C60.2691 22.88 60.0871 23.2777 60.0871 23.6983C60.0871 24.0823 60.2145 24.4206 60.4694 24.7132C60.7334 24.9966 61.1703 25.2663 61.7802 25.5223L65.1255 26.9212C65.9902 27.2777 66.7275 27.7257 67.3374 28.2652C67.9564 28.7954 68.4252 29.4034 68.7438 30.0892C69.0715 30.7657 69.2353 31.5017 69.2353 32.2972C69.2353 33.3943 68.9713 34.3817 68.4434 35.2594C67.9245 36.1372 67.1371 36.832 66.0812 37.344C65.0344 37.856 63.7191 38.112 62.1352 38.112C61.0247 38.112 59.8504 37.9383 58.6124 37.5909C57.3836 37.2343 56.2685 36.7314 55.2672 36.0823Z" fill="white"/>
<path d="M76.7523 18.9806C77.0891 18.9349 77.4896 18.8983 77.9539 18.8709C78.4181 18.8434 78.8232 18.8297 79.1691 18.8297C79.5059 18.8297 79.9064 18.8434 80.3707 18.8709C80.844 18.8983 81.2445 18.944 81.5722 19.008L87.4844 37.44C87.1931 37.6046 86.8199 37.728 86.3648 37.8103C85.9096 37.9017 85.4818 37.9474 85.0813 37.9474C84.4259 37.9474 83.9116 37.7966 83.5384 37.4949C83.1652 37.184 82.8511 36.5806 82.5963 35.6846L80.5072 28.3749C80.2887 27.5977 80.0384 26.7017 79.7562 25.6869C79.4831 24.672 79.2374 23.7212 79.0189 22.8343H78.855C78.7185 23.5749 78.5182 24.4252 78.2543 25.3852C77.9994 26.336 77.7673 27.168 77.5579 27.8812L74.6359 37.7417C74.3811 37.8057 74.1034 37.8469 73.803 37.8652C73.5026 37.8834 73.1932 37.8926 72.8746 37.8926C72.556 37.8926 72.201 37.8697 71.8095 37.824C71.4181 37.7783 71.0995 37.7097 70.8538 37.6183L70.7036 37.3989L76.7523 18.9806ZM76.9981 33.7097C76.8707 33.7097 76.7159 33.7097 76.5339 33.7097C76.3518 33.7006 76.1697 33.696 75.9877 33.696C75.8147 33.6869 75.6645 33.6823 75.5371 33.6823H73.3661L74.6496 30.4046H76.5475C76.6749 30.4046 76.8251 30.4046 76.9981 30.4046C77.171 30.3954 77.344 30.3909 77.5169 30.3909C77.6899 30.3817 77.8355 30.3772 77.9539 30.3772H80.0429C80.1704 30.3772 80.3206 30.3817 80.4935 30.3909C80.6665 30.3909 80.8394 30.3954 81.0124 30.4046C81.1853 30.4046 81.3355 30.4046 81.463 30.4046H83.4292L84.4805 33.6823H82.3232C82.1957 33.6823 82.041 33.6869 81.8589 33.696C81.6769 33.696 81.4948 33.7006 81.3128 33.7097C81.1398 33.7097 80.9896 33.7097 80.8622 33.7097H76.9981Z" fill="white"/>
<path d="M88.069 19.2274C88.3876 19.0903 88.7426 18.976 89.134 18.8846C89.5254 18.7932 89.9077 18.7474 90.2809 18.7474C91.0092 18.7474 91.5371 18.9074 91.8648 19.2274C92.2016 19.5383 92.4383 20.1417 92.5748 21.0377L93.8037 28.4297C93.9038 29.0057 94.004 29.632 94.1041 30.3086C94.2042 30.9852 94.2952 31.6389 94.3772 32.2697C94.4682 32.8914 94.5365 33.4217 94.582 33.8606C94.6002 34.0343 94.6229 34.1394 94.6502 34.176C94.6867 34.2034 94.7504 34.2172 94.8414 34.2172L97.2036 19.3509C97.6132 19.2777 98.0592 19.2274 98.5417 19.2C99.0241 19.1634 99.4656 19.1452 99.8661 19.1452C100.294 19.1452 100.745 19.1634 101.218 19.2C101.7 19.2274 102.137 19.2777 102.529 19.3509L104.713 33.8606C104.741 34.0343 104.772 34.1394 104.809 34.176C104.854 34.2034 104.918 34.2172 105 34.2172C105.055 33.7052 105.127 33.1109 105.218 32.4343C105.31 31.7577 105.41 31.0629 105.519 30.3497C105.628 29.6366 105.733 28.9829 105.833 28.3886L107.458 18.9806C107.767 18.9166 108.086 18.8754 108.414 18.8572C108.741 18.8389 109.046 18.8297 109.328 18.8297C109.647 18.8297 109.984 18.8526 110.339 18.8983C110.694 18.944 111.012 19.0126 111.295 19.104L111.499 19.3509L107.84 37.6869C107.367 37.7417 106.789 37.7829 106.106 37.8103C105.432 37.8469 104.823 37.8652 104.276 37.8652C103.657 37.8652 103.111 37.76 102.638 37.5497C102.165 37.3394 101.864 36.8137 101.737 35.9726L100.754 29.8423C100.59 28.7909 100.43 27.6846 100.276 26.5234C100.13 25.3623 100.012 24.3292 99.9207 23.424H99.7159C99.6067 24.3292 99.4701 25.3623 99.3063 26.5234C99.1424 27.6754 98.9695 28.7817 98.7874 29.8423L97.4766 37.6869C96.976 37.7417 96.4071 37.7829 95.7699 37.8103C95.1418 37.8469 94.5456 37.8652 93.9812 37.8652C93.3167 37.8652 92.7478 37.76 92.2744 37.5497C91.8102 37.3394 91.4962 36.8092 91.3323 35.9589L88.069 19.2274Z" fill="white"/>
<path d="M118.115 18.9806C118.451 18.9349 118.852 18.8983 119.316 18.8709C119.78 18.8434 120.185 18.8297 120.531 18.8297C120.868 18.8297 121.269 18.8434 121.733 18.8709C122.206 18.8983 122.607 18.944 122.934 19.008L128.847 37.44C128.555 37.6046 128.182 37.728 127.727 37.8103C127.272 37.9017 126.844 37.9474 126.444 37.9474C125.788 37.9474 125.274 37.7966 124.901 37.4949C124.527 37.184 124.213 36.5806 123.958 35.6846L121.869 28.3749C121.651 27.5977 121.401 26.7017 121.118 25.6869C120.845 24.672 120.6 23.7212 120.381 22.8343H120.217C120.081 23.5749 119.88 24.4252 119.617 25.3852C119.362 26.336 119.13 27.168 118.92 27.8812L115.998 37.7417C115.743 37.8057 115.466 37.8469 115.165 37.8652C114.865 37.8834 114.555 37.8926 114.237 37.8926C113.918 37.8926 113.563 37.8697 113.172 37.824C112.78 37.7783 112.462 37.7097 112.216 37.6183L112.066 37.3989L118.115 18.9806ZM118.36 33.7097C118.233 33.7097 118.078 33.7097 117.896 33.7097C117.714 33.7006 117.532 33.696 117.35 33.696C117.177 33.6869 117.027 33.6823 116.899 33.6823H114.728L116.012 30.4046H117.91C118.037 30.4046 118.187 30.4046 118.36 30.4046C118.533 30.3954 118.706 30.3909 118.879 30.3909C119.052 30.3817 119.198 30.3772 119.316 30.3772H121.405C121.533 30.3772 121.683 30.3817 121.856 30.3909C122.029 30.3909 122.202 30.3954 122.375 30.4046C122.548 30.4046 122.698 30.4046 122.825 30.4046H124.791L125.843 33.6823H123.685C123.558 33.6823 123.403 33.6869 123.221 33.696C123.039 33.696 122.857 33.7006 122.675 33.7097C122.502 33.7097 122.352 33.7097 122.224 33.7097H118.36Z" fill="white"/>
<path d="M133.41 29.4172H137.588V37.7143C137.251 37.7783 136.892 37.8194 136.51 37.8377C136.136 37.856 135.809 37.8652 135.526 37.8652C135.272 37.8652 134.939 37.856 134.53 37.8377C134.12 37.8194 133.747 37.7783 133.41 37.7143V29.4172ZM138.954 18.9806C139.245 18.9166 139.532 18.8754 139.814 18.8572C140.096 18.8389 140.374 18.8297 140.647 18.8297C141.002 18.8297 141.361 18.8526 141.725 18.8983C142.09 18.944 142.413 19.0172 142.695 19.1177L142.859 19.3509L137.37 32.448H133.683L128.14 19.4743C128.513 19.264 128.922 19.0949 129.368 18.9669C129.815 18.8297 130.256 18.7612 130.693 18.7612C131.285 18.7612 131.74 18.8937 132.058 19.1589C132.377 19.424 132.655 19.8537 132.891 20.448L134.311 23.9863C134.53 24.5532 134.744 25.1612 134.953 25.8103C135.162 26.4594 135.358 27.1406 135.54 27.8537H135.69C135.809 27.4057 135.941 26.9486 136.086 26.4823C136.232 26.016 136.382 25.5589 136.537 25.1109C136.701 24.6629 136.86 24.2423 137.015 23.8492L138.954 18.9806Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M17.9978 43.3281C20.5208 32.8971 19.5204 21.9194 15.1533 12.1223C23.8314 8.6219 33.1615 7.04718 42.5022 7.50735C42.9362 8.94698 43.3244 10.3982 43.667 11.8579L52.0194 13.536H26.0885V48L24.9804 43.2663C22.6597 43.4062 20.329 43.4278 17.9978 43.3281Z" fill="#E13833"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M17.6248 38.3511C16.4941 29.4258 12.9019 20.9574 7.20915 13.925C14.6893 8.28785 23.296 4.34155 32.437 2.35731C33.2346 3.64812 33.9906 4.96162 34.7036 6.29676C27.8434 6.78015 21.0798 8.33535 14.6725 10.9201L13.4273 11.422L13.9753 12.6516C17.5633 20.7015 18.8148 29.6527 17.6248 38.3511Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M15.8945 37.5263C12.4621 29.6575 6.98999 22.8108 0 17.7315C5.77315 10.3418 13.0689 4.29249 21.3877 0C22.4075 0.960982 23.3973 1.94993 24.3567 2.9658C17.8737 5.22498 11.7596 8.48472 6.24812 12.6389L4.91621 13.6421L5.9665 14.9398C11.251 21.4667 14.663 29.2764 15.8945 37.5263Z" fill="#E13833"/>
<path d="M106.782 37.571C108.845 36.9354 111.436 36.0416 113.189 35.1346C110.17 36.4323 109.294 34.9161 109.188 34.3865C108.114 35.1346 107.105 35.8563 106.242 36.4786C105.675 36.8891 106.116 37.7763 106.782 37.571Z" fill="white"/>
<path d="M132.938 27.1765L141.27 20.3638C141.27 20.3638 138.6 22.8002 138.534 24.0317C138.237 24.3826 132.938 27.1765 132.938 27.1765Z" fill="white"/>
<path d="M132.964 25.1771C132.964 25.1771 139.048 21.0458 140.683 19.5098C138.646 21.0259 137.908 20.8736 137.38 20.6551C134.23 23.8596 132.964 25.1771 132.964 25.1771Z" fill="white"/>
<path d="M121.106 34.9095C121.106 34.9095 126.84 31.5528 128.442 30.4405C127.862 30.8775 126.893 32.1222 126.834 32.9432L121.106 34.9095Z" fill="white"/>
<path d="M152.133 21.6416C151.724 19.0265 151.328 16.5503 150.926 13.9682C150.676 14.1735 150.471 14.339 150.274 14.5045C148.791 15.7956 147.149 16.8284 145.304 17.5236C144.677 17.7553 144.111 17.7487 143.524 17.3779C142.39 16.6562 141.223 15.9876 140.07 15.2924C140.498 14.7296 140.979 14.5376 141.639 14.6171C142.548 14.723 143.471 14.7495 144.387 14.7561C144.664 14.7561 145.007 14.6568 145.218 14.478C148.428 11.7503 151.618 9.00272 154.809 6.24852C155.685 5.49376 156.701 5.11638 157.847 5.14286C158.843 5.16272 159.258 5.79831 158.836 6.70534C158.619 7.17541 158.263 7.61238 157.88 7.96328C156.944 8.82397 155.969 9.63169 154.993 10.446C154.723 10.6711 154.585 10.9029 154.558 11.2736C154.347 13.9947 154.09 16.7092 153.892 19.4303C153.846 20.0328 153.649 20.4764 153.148 20.814C152.818 21.0325 152.541 21.304 152.133 21.6416Z" fill="white"/>
<path d="M143.32 10.2275C144.064 9.57872 144.585 8.75114 145.785 8.92989C146.991 9.11527 148.224 9.12189 149.45 9.20796C149.549 9.21458 149.654 9.23445 149.858 9.26093C148.949 10.0289 148.118 10.7373 147.274 11.4325C147.195 11.4987 147.037 11.5384 146.938 11.5053C145.818 11.1544 144.704 10.7903 143.59 10.4196C143.504 10.4063 143.445 10.3269 143.32 10.2275Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -24,6 +24,7 @@ const insuranceController = require("../controllers/insuranceController");
const activityController = require("../controllers/activityController");
const bookingSubmissionController = require("../controllers/bookingSubmissionController");
const serviceController = require("../controllers/serviceController");
const headerMenuController = require("../controllers/headerMenuController");
// Blog controllers
const blogController = require("../controllers/blogController");
@@ -39,8 +40,8 @@ router.post("/home/update", ensureAuthenticated, homeController.update);
// Middleware chuẩn hóa code
router.param("code", (req, res, next, code) => {
req.params.code = code.toUpperCase();
next();
req.params.code = code.toUpperCase();
next();
});
// About
@@ -49,99 +50,49 @@ router.post("/about/update", ensureAuthenticated, aboutController.update);
// AboutUs admin CRUD
router.get("/about-us", ensureAuthenticated, aboutUsController.index);
router.get(
"/about-us/create",
ensureAuthenticated,
aboutUsController.createForm,
);
router.get("/about-us/create", ensureAuthenticated, aboutUsController.createForm);
router.post("/about-us/create", ensureAuthenticated, aboutUsController.create);
router.get(
"/about-us/:id/edit",
ensureAuthenticated,
aboutUsController.editForm,
);
router.post(
"/about-us/:id/update",
ensureAuthenticated,
aboutUsController.update,
);
router.post(
"/about-us/:id/delete",
ensureAuthenticated,
aboutUsController.delete,
);
router.get(
"/about-us/:id/preview",
ensureAuthenticated,
aboutUsController.preview,
);
router.get("/about-us/:id/edit", ensureAuthenticated, aboutUsController.editForm);
router.post("/about-us/:id/update", ensureAuthenticated, aboutUsController.update);
router.post("/about-us/:id/delete", ensureAuthenticated, aboutUsController.delete);
router.get("/about-us/:id/preview", ensureAuthenticated, aboutUsController.preview);
// Booking admin CRUD removed
// Form Management
router.get("/form", ensureAuthenticated, formController.index);
router.post(
"/form/update",
ensureAuthenticated,
formController.updateDefaultForm,
);
router.post("/form/update", ensureAuthenticated, formController.updateDefaultForm);
// Upload routes
router.get("/upload", ensureAuthenticated, (req, res) => {
res.render("admin/upload/index", {
layout: "layouts/admin",
title: "Quản lý Upload Ảnh",
user: req.session.user,
});
res.render("admin/upload/index", {
layout: "layouts/admin",
title: "Quản lý Upload Ảnh",
user: req.session.user,
});
});
router.post(
"/upload/image",
ensureAuthenticated,
upload.single("image"),
// convertToWebp, // Disabled to keep original image format (JPG/PNG)
uploadController.uploadImage,
);
router.post(
"/upload/video",
ensureAuthenticated,
uploadVideo.single("video"),
uploadController.uploadVideo,
);
router.post(
"/upload/update-path",
ensureAuthenticated,
uploadController.updateImagePath,
);
router.post(
"/upload/delete",
ensureAuthenticated,
uploadController.deleteImage,
);
router.post("/upload/image", ensureAuthenticated, upload.single("image"), uploadController.uploadImage);
router.post("/upload/video", ensureAuthenticated, uploadVideo.single("video"), uploadController.uploadVideo);
router.post("/upload/update-path", ensureAuthenticated, uploadController.updateImagePath);
router.post("/upload/delete", ensureAuthenticated, uploadController.deleteImage);
// Header routes
// Header routes
router.get("/header", ensureAuthenticated, headerController.index);
router.post("/header/update", ensureAuthenticated, headerController.update);
router.post(
"/header/update-menu",
ensureAuthenticated,
headerController.updateMenu,
);
router.get(
"/header/menu-tree",
ensureAuthenticated,
headerController.getMenuTree,
);
router.get(
"/header/programmes/:menuId",
ensureAuthenticated,
headerController.getProgrammesByMenuId,
);
router.get(
"/header/menu-item/:menuId",
ensureAuthenticated,
headerController.getMenuItem,
);
router.post("/header/update-menu", ensureAuthenticated, headerController.updateMenu);
router.get("/header/menu-tree", ensureAuthenticated, headerController.getMenuTree);
router.get("/header/programmes/:menuId", ensureAuthenticated, headerController.getProgrammesByMenuId);
router.get("/header/menu-item/:menuId", ensureAuthenticated, headerController.getMenuItem);
router.get("/header/data", ensureAuthenticated, headerController.getHeaderData);
router.patch("/header/:id/status", ensureAuthenticated, headerController.updateStatus);
router.delete("/header/:id", ensureAuthenticated, headerController.destroy);
// Header Menu INTEGRATED routes
router.post("/header/menu/create", ensureAuthenticated, headerMenuController.createMenu);
router.post("/header/menu/update/:id", ensureAuthenticated, headerMenuController.updateMenu);
router.post("/header/menu/delete", ensureAuthenticated, headerMenuController.deleteMenu);
router.post("/header/menu/reorder", ensureAuthenticated, headerMenuController.reorderMenu);
// Footer routes
router.get("/footer", ensureAuthenticated, footerController.index);
@@ -151,160 +102,60 @@ router.get("/footer/data", ensureAuthenticated, footerController.getFooterData);
// Contact routes
router.get("/contact", ensureAuthenticated, contactController.index);
router.post("/contact/update", ensureAuthenticated, contactController.update);
router.get(
"/contact/data",
ensureAuthenticated,
contactController.getContactData,
);
router.get("/contact/data", ensureAuthenticated, contactController.getContactData);
// Contact submissions management
router.get(
"/contact/submissions",
ensureAuthenticated,
contactController.getSubmissions,
);
router.put(
"/contact/submissions/:id",
ensureAuthenticated,
contactController.updateSubmissionStatus,
);
router.get("/contact/submissions", ensureAuthenticated, contactController.getSubmissions);
router.put("/contact/submissions/:id", ensureAuthenticated, contactController.updateSubmissionStatus);
// Appointment management
const appointmentController = require("../controllers/appointmentController");
router.get(
"/appointments",
ensureAuthenticated,
appointmentController.getAppointments,
);
router.get(
"/appointments/:id",
ensureAuthenticated,
appointmentController.getAppointmentById,
);
router.put(
"/appointments/:id",
ensureAuthenticated,
appointmentController.updateAppointmentStatus,
);
router.delete(
"/appointments/:id",
ensureAuthenticated,
appointmentController.deleteAppointment,
);
router.get("/appointments", ensureAuthenticated, appointmentController.getAppointments);
router.get("/appointments/:id", ensureAuthenticated, appointmentController.getAppointmentById);
router.put("/appointments/:id", ensureAuthenticated, appointmentController.updateAppointmentStatus);
router.delete("/appointments/:id", ensureAuthenticated, appointmentController.deleteAppointment);
// Appointment CMS page management
router.get("/appointment", ensureAuthenticated, appointmentController.index);
router.post(
"/appointment/update",
ensureAuthenticated,
appointmentController.update,
);
router.get(
"/appointment/data",
ensureAuthenticated,
appointmentController.getAppointmentData,
);
router.post("/appointment/update", ensureAuthenticated, appointmentController.update);
router.get("/appointment/data", ensureAuthenticated, appointmentController.getAppointmentData);
// Pricing CMS page management
const pricingController = require("../controllers/pricingController");
router.get("/pricing", ensureAuthenticated, pricingController.index);
router.post("/pricing/update", ensureAuthenticated, pricingController.update);
router.get(
"/pricing/data",
ensureAuthenticated,
pricingController.getPricingData,
);
router.get("/pricing/data", ensureAuthenticated, pricingController.getPricingData);
// Activity CRUD routes
router.get("/activity", ensureAuthenticated, activityController.index);
router.get(
"/activity/create",
ensureAuthenticated,
activityController.createForm,
);
router.get("/activity/create", ensureAuthenticated, activityController.createForm);
router.post("/activity/create", ensureAuthenticated, activityController.create);
// Update filters (place before any parameterized /activity/:id routes to avoid route collision)
router.post(
"/activity/filters/update",
ensureAuthenticated,
activityController.updateFilters,
);
router.post("/activity/filters/update", ensureAuthenticated, activityController.updateFilters);
// Update hero (global hero section for activities)
router.post(
"/activity/hero/update",
ensureAuthenticated,
activityController.updateHero,
);
router.get(
"/activity/:id/edit",
ensureAuthenticated,
activityController.editForm,
);
router.post(
"/activity/:id/update",
ensureAuthenticated,
activityController.update,
);
router.post(
"/activity/:id/delete",
ensureAuthenticated,
activityController.delete,
);
router.post(
"/activity/:id/toggle-status",
ensureAuthenticated,
activityController.toggleStatus,
);
router.post("/activity/hero/update", ensureAuthenticated, activityController.updateHero);
router.get("/activity/:id/edit", ensureAuthenticated, activityController.editForm);
router.post("/activity/:id/update", ensureAuthenticated, activityController.update);
router.post("/activity/:id/delete", ensureAuthenticated, activityController.delete);
router.post("/activity/:id/toggle-status", ensureAuthenticated, activityController.toggleStatus);
// Update display order
router.post(
"/activity/update-order",
ensureAuthenticated,
activityController.updateOrder,
);
router.post("/activity/update-order", ensureAuthenticated, activityController.updateOrder);
// Booking submissions routes
router.get(
"/activity/:id/bookings/count",
ensureAuthenticated,
activityController.getBookingCount,
);
router.get(
"/activity/:id/bookings",
ensureAuthenticated,
activityController.getBookingSubmissions,
);
router.get(
"/activity/:id/bookings/export",
ensureAuthenticated,
activityController.exportBookingData,
);
router.get("/activity/:id/bookings/count", ensureAuthenticated, activityController.getBookingCount);
router.get("/activity/:id/bookings", ensureAuthenticated, activityController.getBookingSubmissions);
router.get("/activity/:id/bookings/export", ensureAuthenticated, activityController.exportBookingData);
// Export all bookings (across all activities)
router.get(
"/bookings/export-all",
ensureAuthenticated,
activityController.exportAllBookingsData,
);
router.get("/bookings/export-all", ensureAuthenticated, activityController.exportAllBookingsData);
// Update booking submission
router.put(
"/bookings/:bookingId",
ensureAuthenticated,
bookingSubmissionController.updateBookingSubmission,
);
router.put("/bookings/:bookingId", ensureAuthenticated, bookingSubmissionController.updateBookingSubmission);
// Delete booking submission
router.delete(
"/bookings/:bookingId",
ensureAuthenticated,
bookingSubmissionController.deleteBookingSubmission,
);
router.delete("/bookings/:bookingId", ensureAuthenticated, bookingSubmissionController.deleteBookingSubmission);
// Update filters
// Preview activity
router.get(
"/activity/:id/preview",
ensureAuthenticated,
activityController.preview,
);
router.get("/activity/:id/preview", ensureAuthenticated, activityController.preview);
// FAQ routes - Thêm vào đây
router.get("/faq", ensureAuthenticated, faqController.index);
@@ -413,65 +264,61 @@ router.post(
// Test Image Paths route
router.get("/test-images", ensureAuthenticated, (req, res) => {
const fs = require("fs");
const path = require("path");
const campLocationData = require("../data/camp-location.json");
const fs = require("fs");
const path = require("path");
const campLocationData = require("../data/camp-location.json");
// Collect all image paths
const imagePaths = [];
// Collect all image paths
const imagePaths = [];
// Camps images
if (campLocationData.camps) {
campLocationData.camps.forEach((camp) => {
if (camp.image) {
imagePaths.push({
type: "Camp",
name: camp.title,
path: camp.image,
exists: fs.existsSync(path.join(__dirname, "../public", camp.image)),
// Camps images
if (campLocationData.camps) {
campLocationData.camps.forEach((camp) => {
if (camp.image) {
imagePaths.push({
type: "Camp",
name: camp.title,
path: camp.image,
exists: fs.existsSync(path.join(__dirname, "../public", camp.image)),
});
}
});
}
}
// Locations images
if (campLocationData.locations) {
campLocationData.locations.forEach((location) => {
if (location.imageSrc) {
imagePaths.push({
type: "Location",
name: location.title,
path: location.imageSrc,
exists: fs.existsSync(path.join(__dirname, "../public", location.imageSrc)),
});
}
// Program images
if (location.programOptions) {
location.programOptions.forEach((program) => {
if (program.imageSrc) {
imagePaths.push({
type: "Program",
name: program.title,
path: program.imageSrc,
exists: fs.existsSync(path.join(__dirname, "../public", program.imageSrc)),
});
}
});
}
});
}
res.render("admin/test-images", {
layout: "layouts/admin",
title: "Test Image Paths",
images: imagePaths,
user: req.session.user,
});
}
// Locations images
if (campLocationData.locations) {
campLocationData.locations.forEach((location) => {
if (location.imageSrc) {
imagePaths.push({
type: "Location",
name: location.title,
path: location.imageSrc,
exists: fs.existsSync(
path.join(__dirname, "../public", location.imageSrc),
),
});
}
// Program images
if (location.programOptions) {
location.programOptions.forEach((program) => {
if (program.imageSrc) {
imagePaths.push({
type: "Program",
name: program.title,
path: program.imageSrc,
exists: fs.existsSync(
path.join(__dirname, "../public", program.imageSrc),
),
});
}
});
}
});
}
res.render("admin/test-images", {
layout: "layouts/admin",
title: "Test Image Paths",
images: imagePaths,
user: req.session.user,
});
});
// Display visa management page

View File

@@ -5,10 +5,12 @@ const homeController = require("../controllers/homeController");
const aboutController = require("../controllers/aboutController");
const aboutUsController = require("../controllers/aboutUsController");
const headerController = require("../controllers/headerController");
const socialLinkController = require("../controllers/socialLinkController");
const footerController = require("../controllers/footerController");
const contactController = require("../controllers/contactController");
const faqController = require("../controllers/faqController");
const visaController = require("../controllers/visaController");
const headerMenuController = require("../controllers/headerMenuController");
const safetyController = require("../controllers/safetyController");
const campLocationController = require("../controllers/campLocationController");
// Booking flow removed
@@ -27,10 +29,10 @@ const blogTagController = require("../controllers/blogTagController");
// Trang chủ
router.get("/", (req, res) => {
res.render("index", {
title: "Welcome",
layout: "layouts/main",
});
res.render("index", {
title: "Welcome",
layout: "layouts/main",
});
});
// API để lấy dữ liệu trang chủ
@@ -50,6 +52,17 @@ 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);
// Social Links API routes
router.get("/api/social-links", socialLinkController.getAll);
router.get("/api/social-links/:platform", socialLinkController.show);
router.post("/api/social-links", socialLinkController.store);
router.put("/api/social-links/:platform", socialLinkController.update);
router.delete("/api/social-links/:platform", socialLinkController.destroy);
router.post("/api/social-links/bulk-update", socialLinkController.bulkUpdate);
// Footer API route
router.get("/api/footer", footerController.api);
@@ -85,69 +98,57 @@ router.get("/api/terms", termsController.api);
// Travel public page and API
router.get("/travel", async (req, res) => {
try {
const Travel = require("../models/travel");
const travel = await Travel.findOne();
try {
const Travel = require("../models/travel");
const travel = await Travel.findOne();
if (!travel) {
return res.status(404).render("errors/404", {
title: "Page Not Found",
message: "Travel information not found",
});
if (!travel) {
return res.status(404).render("errors/404", {
title: "Page Not Found",
message: "Travel information not found",
});
}
res.render("page/travel", {
title: travel.page.title,
data: travel.toObject(),
});
} catch (error) {
console.error("Error loading travel page:", error);
res.status(500).render("errors/500", {
title: "Server Error",
message: "Error loading travel page",
});
}
res.render("page/travel", {
title: travel.page.title,
data: travel.toObject(),
});
} catch (error) {
console.error("Error loading travel page:", error);
res.status(500).render("errors/500", {
title: "Server Error",
message: "Error loading travel page",
});
}
});
router.get("/api/travel", travelController.api);
// Booking submission APIs (public endpoints)
router.post("/api/booking/submit", bookingSubmissionController.submitBooking);
router.get("/api/activity/:activityId/sessions", bookingSubmissionController.getAvailableSessions);
router.get(
"/api/activity/:activityId/sessions",
bookingSubmissionController.getAvailableSessions,
);
router.get(
"/api/activity/:activityId/session/:sessionId/availability",
bookingSubmissionController.getSessionAvailability,
"/api/activity/:activityId/session/:sessionId/availability",
bookingSubmissionController.getSessionAvailability,
);
// New API for creating bookings directly into camp sessions (by program)
router.post(
"/api/camps/:program/sessions/:sessionId/bookings",
activityController.createSessionBookingByProgram,
);
router.get(
"/api/camps/:program/sessions/:sessionId/bookings",
activityController.getSessionBookingsByProgram,
);
router.post("/api/camps/:program/sessions/:sessionId/bookings", activityController.createSessionBookingByProgram);
router.get("/api/camps/:program/sessions/:sessionId/bookings", activityController.getSessionBookingsByProgram);
// Keep admin-style update/delete by activityId (protected) if needed
router.put(
"/api/camps/:activityId/sessions/:sessionId/bookings/:bookingId",
activityController.updateSessionBooking,
);
router.put("/api/camps/:activityId/sessions/:sessionId/bookings/:bookingId", activityController.updateSessionBooking);
router.delete(
"/api/camps/:activityId/sessions/:sessionId/bookings/:bookingId",
activityController.deleteSessionBooking,
"/api/camps/:activityId/sessions/:sessionId/bookings/:bookingId",
activityController.deleteSessionBooking,
);
// Demo booking form
router.get("/demo/booking-form", (req, res) => {
res.sendFile(path.join(__dirname, "../views/demo/booking-form.html"));
res.sendFile(path.join(__dirname, "../views/demo/booking-form.html"));
});
// Demo session booking API
router.get("/demo/session-booking-api", (req, res) => {
res.sendFile(path.join(__dirname, "../views/demo/session-booking-api.html"));
res.sendFile(path.join(__dirname, "../views/demo/session-booking-api.html"));
});
// Blog API Routes

View File

@@ -0,0 +1,69 @@
const mongoose = require('mongoose');
const fs = require('fs');
const path = require('path');
const dotenv = require('dotenv');
const HeaderMenu = require('../models/HeaderMenu');
dotenv.config();
const MONGODB_URI = process.env.MONGODB_URI || 'mongodb://localhost:27017/SIMS';
async function connectDB() {
try {
await mongoose.connect(MONGODB_URI);
console.log('✅ MongoDB Connected for Migration');
} catch (err) {
console.error('❌ MongoDB Connection Error:', err);
process.exit(1);
}
}
const processMenuItems = async (items, parentId = null) => {
for (const item of items) {
console.log(` > Importing: ${item.label}`);
const menuDoc = {
title: item.label,
slug: item.slug,
url: item.href,
parentId: parentId,
order: item.order || 0,
status: item.isActive === false ? "inactive" : "active",
type: item.type === "external" ? "external" : "internal"
};
const createdItem = await HeaderMenu.create(menuDoc);
if (item.children && item.children.length > 0) {
await processMenuItems(item.children, createdItem._id);
}
}
};
async function migrate() {
await connectDB();
try {
console.log('--- Starting Header Menu Migration ---');
// 1. Clear existing menu items
await HeaderMenu.deleteMany({});
console.log('🗑️ Cleared existing HeaderMenu collection');
// 2. Read JSON data
const dataPath = path.join(__dirname, '../data/header-menu.json');
const fileData = fs.readFileSync(dataPath, 'utf8');
const menuItems = JSON.parse(fileData);
// 3. Recursive import
await processMenuItems(menuItems);
console.log('--- Migration Completed Successfully ---');
process.exit(0);
} catch (error) {
console.error('❌ Migration Failed:', error);
process.exit(1);
}
}
migrate();

73
scripts/migrate-header.js Normal file
View File

@@ -0,0 +1,73 @@
const mongoose = require("mongoose");
const path = require("path");
require("dotenv").config({ path: path.join(__dirname, "../.env") });
const Header = require("../models/header");
const headerData = require("../data/header.json");
const migrateHeader = async () => {
try {
const mongoUri = process.env.MONGODB_URI;
if (!mongoUri) {
throw new Error("MONGODB_URI not found in environment variables");
}
await mongoose.connect(mongoUri);
console.log("Connected to MongoDB");
// Delete existing header
await Header.deleteMany({});
console.log("Cleared existing headers");
// Transform and insert data
const headerDocument = {
top: {
phone: headerData.top.phone,
email: headerData.top.email,
location: headerData.top.location,
socialLinks: headerData.top.socialLinks.map((link, idx) => ({
...link,
order: idx,
})),
languages: headerData.top.languages,
},
offcanvas: headerData.offcanvas,
menu: headerData.menu.map((item, idx) => ({
...item,
order: idx,
children:
item.children?.map((child, childIdx) => ({
...child,
order: childIdx,
children:
child.children?.map((subchild, subIdx) => ({
...subchild,
order: subIdx,
})) || [],
})) || [],
})),
logo: {
light: "/assets/img/logo/white-logo.svg",
dark: "/assets/img/logo/black-logo.svg",
alt: "Hai Learning",
},
ctaButton: {
label: "Get Started",
href: "/contact",
style: "primary",
},
status: "active",
order: 1,
};
const result = await Header.create(headerDocument);
console.log("Header migrated successfully:", result._id);
await mongoose.connection.close();
process.exit(0);
} catch (error) {
console.error("Migration error:", error);
process.exit(1);
}
};
migrateHeader();

View File

@@ -0,0 +1,106 @@
const mongoose = require("mongoose");
const path = require("path");
require("dotenv").config({ path: path.join(__dirname, "../.env") });
const Header = require("../models/header");
async function updateHeaderData() {
try {
// Connect to MongoDB
await mongoose.connect(process.env.MONGODB_URI || "mongodb://localhost:27017/hailearning");
console.log("Connected to MongoDB");
// Find the first header
let header = await Header.findOne().sort({ order: 1 });
if (!header) {
console.log("No header found, creating new one...");
header = new Header({
top: {
phone: "+09 378 357 5222",
email: "info@hailearning.edu.vn",
location: "69 Street, 5th Avenue LA, United States",
socialLinks: [
{
platform: "linkedin",
url: "https://linkedin.com",
icon: "fa-brands fa-linkedin",
},
{
platform: "twitter",
url: "https://twitter.com",
icon: "fa-brands fa-twitter",
},
{
platform: "instagram",
url: "https://instagram.com",
icon: "fa-brands fa-instagram",
},
{
platform: "youtube",
url: "https://youtube.com",
icon: "fa-brands fa-youtube",
},
],
languages: [
{ name: "English", value: "1" },
{ name: "Bangla", value: "2" },
{ name: "Hindi", value: "3" },
],
},
status: "active",
order: 1,
});
} else {
console.log("Header found, updating...");
// Update existing header
header.top = {
phone: header.top?.phone || "+09 378 357 5222",
email: header.top?.email || "info@hailearning.edu.vn",
location: header.top?.location || "69 Street, 5th Avenue LA, United States",
socialLinks:
header.top?.socialLinks?.length > 0
? header.top.socialLinks
: [
{
platform: "linkedin",
url: "https://linkedin.com",
icon: "fa-brands fa-linkedin",
},
{
platform: "twitter",
url: "https://twitter.com",
icon: "fa-brands fa-twitter",
},
{
platform: "instagram",
url: "https://instagram.com",
icon: "fa-brands fa-instagram",
},
{
platform: "youtube",
url: "https://youtube.com",
icon: "fa-brands fa-youtube",
},
],
languages: header.top?.languages || [
{ name: "English", value: "1" },
{ name: "Bangla", value: "2" },
{ name: "Hindi", value: "3" },
],
};
}
await header.save();
console.log("Header updated successfully!");
console.log("Header data:", JSON.stringify(header, null, 2));
await mongoose.connection.close();
console.log("Database connection closed");
} catch (error) {
console.error("Error updating header:", error);
process.exit(1);
}
}
updateHeaderData();

162
server.js
View File

@@ -34,24 +34,29 @@ app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(cookieParser());
app.use(
"/assets",
(req, res, next) => {
// Cho phép mọi domain truy cập tài nguyên tĩnh
res.header("Access-Control-Allow-Origin", "*");
res.header("Access-Control-Allow-Methods", "GET");
next();
},
express.static(path.join(__dirname, "assets")),
"/assets",
(req, res, next) => {
// Cho phép mọi domain truy cập tài nguyên tĩnh
res.header("Access-Control-Allow-Origin", "*");
res.header("Access-Control-Allow-Methods", "GET");
next();
},
express.static(path.join(__dirname, "assets")),
);
// Serve public folder at root (for /js, /img, etc.)
app.use(express.static(path.join(__dirname, "public")));
// Serve uploads folder
app.use(
"/uploads",
(req, res, next) => {
// Cho phép mọi domain truy cập tài nguyên tĩnh
res.header("Access-Control-Allow-Origin", "*");
res.header("Access-Control-Allow-Methods", "GET");
next();
},
express.static(path.join(__dirname, "public", "uploads")),
"/uploads",
(req, res, next) => {
// Cho phép mọi domain truy cập tài nguyên tĩnh
res.header("Access-Control-Allow-Origin", "*");
res.header("Access-Control-Allow-Methods", "GET");
next();
},
express.static(path.join(__dirname, "public", "uploads")),
);
// Serve other public files
@@ -59,21 +64,21 @@ app.use(express.static(path.join(__dirname, "public")));
// Session configuration (using MongoDB store to avoid logout khi server restart)
app.use(
session({
secret: process.env.SESSION_SECRET || "secret",
resave: false,
saveUninitialized: false,
store: MongoStore.create({
mongoUrl: process.env.MONGODB_URI,
collectionName: "sessions",
ttl: 60 * 60 * 24, // 24 hours (in seconds)
session({
secret: process.env.SESSION_SECRET || "secret",
resave: false,
saveUninitialized: false,
store: MongoStore.create({
mongoUrl: process.env.MONGODB_URI,
collectionName: "sessions",
ttl: 60 * 60 * 24, // 24 hours (in seconds)
}),
cookie: {
maxAge: 1000 * 60 * 60 * 24, // 24 hours
httpOnly: true,
sameSite: "lax",
},
}),
cookie: {
maxAge: 1000 * 60 * 60 * 24, // 24 hours
httpOnly: true,
sameSite: "lax",
},
}),
);
// Flash messages
@@ -81,32 +86,32 @@ app.use(flash());
// Global variables
app.use((req, res, next) => {
// Lấy flash messages
const success_msg = req.flash("success_msg");
const error_msg = req.flash("error_msg");
const error = req.flash("error");
// Lấy flash messages
const success_msg = req.flash("success_msg");
const error_msg = req.flash("error_msg");
const error = req.flash("error");
// Lưu vào res.locals để sử dụng trong views
res.locals.success_msg = success_msg.length > 0 ? success_msg[0] : null;
res.locals.error_msg = error_msg.length > 0 ? error_msg[0] : null;
res.locals.error = error.length > 0 ? error[0] : null;
// Lưu vào res.locals để sử dụng trong views
res.locals.success_msg = success_msg.length > 0 ? success_msg[0] : null;
res.locals.error_msg = error_msg.length > 0 ? error_msg[0] : null;
res.locals.error = error.length > 0 ? error[0] : null;
// Tạo object flashMessages để sử dụng trong client-side JavaScript
res.locals.flashMessagesJSON = JSON.stringify({
success_msg: res.locals.success_msg,
error_msg: res.locals.error_msg,
error: res.locals.error,
});
// Tạo object flashMessages để sử dụng trong client-side JavaScript
res.locals.flashMessagesJSON = JSON.stringify({
success_msg: res.locals.success_msg,
error_msg: res.locals.error_msg,
error: res.locals.error,
});
res.locals.user = req.session.user || null;
res.locals.currentPath = req.path;
next();
res.locals.user = req.session.user || null;
res.locals.currentPath = req.path;
next();
});
// Kiểm tra và tạo thư mục data nếu chưa tồn tại
const dataDir = path.join(__dirname, "data");
if (!fs.existsSync(dataDir)) {
fs.mkdirSync(dataDir);
fs.mkdirSync(dataDir);
}
// Frontend URL configuration
@@ -114,39 +119,30 @@ const FRONTEND_URL = process.env.FRONTEND_URL;
// Add frontend URL to res.locals for all requests
app.use((req, res, next) => {
res.locals.frontendUrl = FRONTEND_URL;
res.locals.currentPath = req.path;
next();
res.locals.frontendUrl = FRONTEND_URL;
res.locals.currentPath = req.path;
next();
});
// Simple CORS middleware for API endpoints
app.use((req, res, next) => {
// Allow requests from configured FRONTEND_URL or allow all if not set
const origin = req.headers.origin;
const allowedOrigin = FRONTEND_URL || "*";
// Allow requests from configured FRONTEND_URL or allow all if not set
const origin = req.headers.origin;
const allowedOrigin = FRONTEND_URL || "*";
if (allowedOrigin === "*" || origin === allowedOrigin) {
res.setHeader(
"Access-Control-Allow-Origin",
allowedOrigin === "*" ? "*" : origin,
);
res.setHeader(
"Access-Control-Allow-Methods",
"GET,POST,PUT,DELETE,OPTIONS",
);
res.setHeader(
"Access-Control-Allow-Headers",
"Content-Type, Authorization",
);
res.setHeader("Access-Control-Allow-Credentials", "true");
}
if (allowedOrigin === "*" || origin === allowedOrigin) {
res.setHeader("Access-Control-Allow-Origin", allowedOrigin === "*" ? "*" : origin);
res.setHeader("Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
res.setHeader("Access-Control-Allow-Credentials", "true");
}
// Handle preflight
if (req.method === "OPTIONS") {
return res.sendStatus(204);
}
// Handle preflight
if (req.method === "OPTIONS") {
return res.sendStatus(204);
}
next();
next();
});
// Routes
@@ -160,19 +156,19 @@ app.use("/", indexRoutes);
// 404 handler
app.use((req, res) => {
res.status(404);
if (req.accepts("html"))
return res.render("page/404", {
title: "404 - Page Not Found",
layout: "layouts/main",
});
if (req.accepts("json")) return res.json({ error: "Not found" });
res.type("txt").send("Not found");
res.status(404);
if (req.accepts("html"))
return res.render("page/404", {
title: "404 - Page Not Found",
layout: "layouts/main",
});
if (req.accepts("json")) return res.json({ error: "Not found" });
res.type("txt").send("Not found");
});
// Start server
const PORT = process.env.PORT || 3001;
const HOST = process.env.HOST || "localhost";
app.listen(PORT, HOST, () => {
console.log(`🚀 SERVER:[ http://${HOST}:${PORT} ]`);
console.log(`🚀 SERVER:[ http://${HOST}:${PORT} ]`);
});

View File

@@ -8,6 +8,7 @@
</div>
<div class="card-body p-0">
<div class="row g-0">
<!-- Home -->
<div class="col-md-4 border-end">
<div class="p-4">
<div class="d-flex align-items-center mb-3">
@@ -26,6 +27,7 @@
</div>
</div>
<!-- Header & Menu -->
<div class="col-md-4 border-end">
<div class="p-4">
<div class="d-flex align-items-center mb-3">
@@ -44,6 +46,7 @@
</div>
</div>
<!-- Footer -->
<div class="col-md-4">
<div class="p-4">
<div class="d-flex align-items-center mb-3">
@@ -62,6 +65,7 @@
</div>
</div>
<!-- About Us -->
<div class="col-md-4 border-end border-top">
<div class="p-4">
<div class="d-flex align-items-center mb-3">
@@ -80,6 +84,7 @@
</div>
</div>
<!-- Contact -->
<div class="col-md-4 border-end border-top">
<div class="p-4">
<div class="d-flex align-items-center mb-3">
@@ -98,6 +103,7 @@
</div>
</div>
<!-- FAQ -->
<div class="col-md-4 border-top">
<div class="p-4">
<div class="d-flex align-items-center mb-3">
@@ -116,6 +122,7 @@
</div>
</div>
<!-- Appointment -->
<div class="col-md-4 border-end border-top">
<div class="p-4">
<div class="d-flex align-items-center mb-3">
@@ -134,6 +141,7 @@
</div>
</div>
<!-- Pricing -->
<div class="col-md-4 border-end border-top">
<div class="p-4">
<div class="d-flex align-items-center mb-3">
@@ -152,7 +160,8 @@
</div>
</div>
<div class="col-md-4 border-end border-top">
<!-- Terms & Conditions -->
<div class="col-md-4 border-top">
<div class="p-4">
<div class="d-flex align-items-center mb-3">
<div class="rounded-circle d-flex align-items-center justify-content-center me-3"
@@ -164,15 +173,13 @@
<p class="text-muted mb-0 small">Manage terms</p>
</div>
</div>
<a
href="/admin/terms-conditions"
class="btn btn-sm btn-primary w-100 mt-2"
>
<a href="/admin/terms-conditions" class="btn btn-sm btn-primary w-100 mt-2">
<i class="fas fa-edit me-2"></i>Edit
</a>
</div>
</div>
<!-- Travel -->
<div class="col-md-4 border-end border-top">
<div class="p-4">
<div class="d-flex align-items-center mb-3">
@@ -191,7 +198,8 @@
</div>
</div>
<div class="col-md-4 border-top">
<!-- Safety -->
<div class="col-md-4 border-end border-top">
<div class="p-4">
<div class="d-flex align-items-center mb-3">
<div class="rounded-circle d-flex align-items-center justify-content-center me-3"
@@ -209,7 +217,8 @@
</div>
</div>
<div class="col-md-4 border-end border-top">
<!-- Camp Location -->
<div class="col-md-4 border-top">
<div class="p-4">
<div class="d-flex align-items-center mb-3">
<div class="rounded-circle d-flex align-items-center justify-content-center me-3"
@@ -221,16 +230,14 @@
<p class="text-muted mb-0 small">Manage camp location</p>
</div>
</div>
<a
href="/admin/camp-location"
class="btn btn-sm btn-primary w-100 mt-2"
>
<a href="/admin/camp-location" class="btn btn-sm btn-primary w-100 mt-2">
<i class="fas fa-edit me-2"></i>Edit
</a>
</div>
</div>
<div class="col-md-4 border-top">
<!-- Activities -->
<div class="col-md-4 border-end border-top">
<div class="p-4">
<div class="d-flex align-items-center mb-3">
<div class="rounded-circle d-flex align-items-center justify-content-center me-3"
@@ -247,21 +254,14 @@
</a>
</div>
</div>
<!-- Services -->
<div class="col-md-4 border-end border-top">
<div class="p-4">
<div class="d-flex align-items-center mb-3">
<div
class="rounded-circle d-flex align-items-center justify-content-center me-3"
style="
width: 50px;
height: 50px;
background-color: rgba(184, 183, 106, 0.1);
"
>
<i
class="fas fa-running fa-lg"
style="color: var(--primary-color)"
></i>
<div class="rounded-circle d-flex align-items-center justify-content-center me-3"
style="width: 50px; height: 50px; background-color: rgba(184, 183, 106, 0.1);">
<i class="fas fa-concierge-bell fa-lg" style="color: var(--primary-color);"></i>
</div>
<div>
<h5 class="mb-0">Services</h5>
@@ -273,6 +273,8 @@
</a>
</div>
</div>
<!-- Blog -->
<div class="col-md-4 border-top">
<div class="p-4">
<div class="d-flex align-items-center mb-3">
@@ -638,12 +640,12 @@
<div class="d-flex align-items-center">
<div class="rounded-circle d-flex align-items-center justify-content-center me-2"
style="width: 32px; height: 32px; background-color: rgba(184, 183, 106, 0.1);">
<i class="fas fa-campground" style="color: var(--primary-color);"></i>
<i class="fas fa-blog" style="color: var(--primary-color);"></i>
</div>
<span>Camp Location API</span>
<span>Blog API</span>
</div>
</td>
<td><code>/api/camp-location</code></td>
<td><code>/api/blog</code></td>
<td>
<span
class="badge"
@@ -651,10 +653,10 @@
>GET</span
>
</td>
<td>API to get camp location data</td>
<td>API to get blog posts</td>
<td>
<a
href="/api/camp-location"
href="/api/blog"
class="btn btn-sm btn-outline-primary"
target="_blank"
>
@@ -687,6 +689,7 @@
style="width: 40px; height: 40px; background-color: rgba(184, 183, 106, 0.1);">
<i class="fas fa-info-circle" style="color: var(--primary-color);"></i>
</div>
</div>
<div>
<div class="text-muted small">Version</div>
<div class="fw-bold" style="color: var(--primary-color)">

View File

@@ -196,7 +196,7 @@
<div class="card border shadow-sm">
<div class="card-header bg-light d-flex justify-content-between align-items-center">
<h6 class="mb-0">Footer Columns</h6>
<button type="button" class="btn btn-outline-primary btn-sm" id="addColumn">
<button type="button" class="btn btn-primary btn-sm" id="addColumn">
<i class="fas fa-plus me-1"></i>Add Column
</button>
</div>
@@ -208,10 +208,11 @@
<div class="d-flex justify-content-between align-items-center">
<h6 class="mb-0">Column <%= columnIndex + 1 %>
</h6>
<button type="button" class="btn btn-outline-danger btn-sm remove-column"
data-column-index="<%= columnIndex %>">
<i class="fas fa-trash"></i>
</button>
<div class="btn-group-action">
<button type="button" class="btn btn-sm remove-column" data-column-index="<%= columnIndex %>">
<i class="fas fa-trash text-action-delete"></i>
</button>
</div>
</div>
</div>
<div class="card-body">
@@ -244,20 +245,20 @@
name="columns[<%= columnIndex %>][links][<%= linkIndex %>][url]"
value="<%= link.url %>" placeholder="/about-us/" />
</div>
<div class="col-md-1">
<label class="form-label form-label-sm">&nbsp;</label>
<button type="button"
class="btn btn-outline-danger btn-sm w-100 remove-link"
data-column-index="<%= columnIndex %>" data-link-index="<%= linkIndex %>">
<i class="fas fa-trash"></i>
</button>
<div class="col-md-1 d-flex justify-content-end align-items-end">
<div class="btn-group-action">
<button type="button" class="btn btn-sm remove-link"
data-column-index="<%= columnIndex %>" data-link-index="<%= linkIndex %>">
<i class="fas fa-trash text-action-delete"></i>
</button>
</div>
</div>
</div>
</div>
</div>
<% }); %>
</div>
<button type="button" class="btn btn-outline-primary btn-sm add-link"
<button type="button" class="btn btn-primary btn-sm add-link"
data-column-index="<%= columnIndex %>">
<i class="fas fa-plus me-1"></i>Add Link
</button>
@@ -276,7 +277,7 @@
<div class="card border shadow-sm">
<div class="card-header bg-light d-flex justify-content-between align-items-center">
<h6 class="mb-0">Social Media Links</h6>
<button type="button" class="btn btn-outline-primary btn-sm" id="addSocialLink">
<button type="button" class="btn btn-primary btn-sm" id="addSocialLink">
<i class="fas fa-plus me-1"></i>Add Social Link
</button>
</div>
@@ -301,12 +302,13 @@
<input type="text" class="form-control" name="social[links][<%= index %>][icon]"
value="<%= link.icon %>" />
</div>
<div class="col-md-1">
<label class="form-label">&nbsp;</label>
<button type="button" class="btn btn-outline-danger btn-sm w-100 remove-social-link"
data-social-index="<%= index %>">
<i class="fas fa-trash"></i>
</button>
<div class="col-md-1 d-flex justify-content-end align-items-end">
<div class="btn-group-action">
<button type="button" class="btn btn-sm remove-social-link"
data-social-index="<%= index %>">
<i class="fas fa-trash text-action-delete"></i>
</button>
</div>
</div>
</div>
</div>
@@ -341,13 +343,11 @@
<!-- 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 type="reset" class="btn btn-secondary px-4">
<i class="fas fa-undo me-1"></i>Reset
</button>
<button type="submit" class="btn btn-primary">
<i class="fas fa-save"></i>
<span>Save Changes</span>
<button type="submit" class="btn btn-primary px-4">
<i class="fas fa-save me-1"></i>Save Changes
</button>
</div>
</form>
@@ -433,9 +433,11 @@
<div class="card-header bg-light">
<div class="d-flex justify-content-between align-items-center">
<h6 class="mb-0">Column ${columnIndex + 1}</h6>
<button type="button" class="btn btn-outline-danger btn-sm remove-column" data-column-index="${columnIndex}">
<i class="fas fa-trash"></i>
</button>
<div class="btn-group-action">
<button type="button" class="btn btn-sm remove-column" data-column-index="${columnIndex}">
<i class="fas fa-trash text-action-delete"></i>
</button>
</div>
</div>
</div>
<div class="card-body">
@@ -451,7 +453,7 @@
<h6 class="fw-medium mb-3">Links</h6>
<div class="column-links-container" data-column-index="${columnIndex}">
</div>
<button type="button" class="btn btn-outline-primary btn-sm add-link" data-column-index="${columnIndex}">
<button type="button" class="btn btn-primary btn-sm add-link" data-column-index="${columnIndex}">
<i class="fas fa-plus me-1"></i>Add Link
</button>
</div>
@@ -485,11 +487,12 @@
<label class="form-label fw-medium">Icon Class</label>
<input type="text" class="form-control" name="social[links][${socialLinkIndex}][icon]" value="">
</div>
<div class="col-md-1">
<label class="form-label">&nbsp;</label>
<button type="button" class="btn btn-outline-danger btn-sm w-100 remove-social-link" data-social-index="${socialLinkIndex}">
<i class="fas fa-trash"></i>
</button>
<div class="col-md-1 d-flex justify-content-end align-items-end">
<div class="btn-group-action">
<button type="button" class="btn btn-sm remove-social-link" data-social-index="${socialLinkIndex}">
<i class="fas fa-trash text-action-delete"></i>
</button>
</div>
</div>
</div>
</div>
@@ -535,11 +538,12 @@
<label class="form-label form-label-sm">URL</label>
<input type="text" class="form-control form-control-sm" name="columns[${columnIndex}][links][${linkIndex}][url]" value="" placeholder="/about-us/">
</div>
<div class="col-md-1">
<label class="form-label form-label-sm">&nbsp;</label>
<button type="button" class="btn btn-outline-danger btn-sm w-100 remove-link" data-column-index="${columnIndex}" data-link-index="${linkIndex}">
<i class="fas fa-trash"></i>
</button>
<div class="col-md-1 d-flex justify-content-end align-items-end">
<div class="btn-group-action">
<button type="button" class="btn btn-sm remove-link" data-column-index="${columnIndex}" data-link-index="${linkIndex}">
<i class="fas fa-trash text-action-delete"></i>
</button>
</div>
</div>
</div>
</div>

File diff suppressed because it is too large Load Diff

355
views/admin/header/menu.ejs Normal file
View File

@@ -0,0 +1,355 @@
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h6 class="mb-0">
<i class="fas fa-sitemap me-2"></i>Menu Structure
</h6>
<button class="btn btn-primary btn-sm" onclick="prepareAddMenu()">
<i class="fas fa-plus me-1"></i>Add Root Menu
</button>
</div>
<div class="card-body p-0">
<div id="nestedMenuContainer" class="nested-menu-container">
<ul id="menuRoot" class="list-unstyled menu-group" data-id="root">
<% function renderMenu(items) { %>
<% items.forEach(item => { %>
<li class="menu-item-wrapper mb-2" data-id="<%= item._id %>">
<div class="menu-item-row d-flex align-items-center p-3 rounded border hover-shadow-sm bg-white">
<div class="menu-drag-handle me-2 text-muted">
<i class="fas fa-grip-vertical"></i>
</div>
<div class="menu-toggle-icon me-2">
<% if (item.children && item.children.length > 0) { %>
<button class="btn btn-sm p-0 btn-toggle-nested" type="button">
<i class="fas fa-chevron-down transition-base text-muted" style="width: 12px;"></i>
</button>
<% } else { %>
<div style="width: 24px;"></div>
<% } %>
</div>
<div class="menu-icon me-3">
<i class="fas <%= (item.type === 'external' ? 'fa-external-link-alt text-info' : 'fa-link text-secondary') %>"></i>
</div>
<div class="flex-grow-1">
<div class="d-flex align-items-center">
<span class="fw-bold text-dark"><%= item.title %></span>
<% if (item.type === 'external') { %>
<span class="badge bg-light text-dark border ms-2" style="font-size: 0.7rem;">External</span>
<% } %>
<% if (item.status === 'inactive') { %>
<span class="badge bg-soft-secondary ms-2">Inactive</span>
<% } else { %>
<span class="badge bg-soft-success ms-2">Active</span>
<% } %>
</div>
<div class="text-muted small text-truncate" style="max-width: 300px;">
<i class="fas fa-link me-1" style="font-size: 0.75rem;"></i><%= item.url %>
</div>
</div>
<div class="menu-actions">
<div class="btn-group-action">
<button class="btn btn-sm btn-add-child" data-id="<%= item._id %>" title="Add Sub-menu">
<i class="fas fa-plus text-action-add"></i>
</button>
<button class="btn btn-sm btn-edit-menu" data-item='<%= JSON.stringify(item).replace(/'/g, "&apos;") %>' title="Edit">
<i class="fas fa-edit text-action-edit"></i>
</button>
<button type="button" class="btn btn-sm btn-delete-menu" data-id="<%= item._id %>" data-title="<%= item.title %>" title="Delete">
<i class="fas fa-trash text-action-delete"></i>
</button>
</div>
<form id="delete-form-<%= item._id %>" action="/admin/header/menu/delete" method="POST" class="d-none">
<input type="hidden" name="id" value="<%= item._id %>">
</form>
</div>
</div>
<ul class="list-unstyled menu-group mt-2 ps-4 ms-2 border-start nested-list" style="min-height: 5px;" data-id="<%= item._id %>">
<% if (item.children && item.children.length > 0) { %>
<% renderMenu(item.children) %>
<% } %>
</ul>
</li>
<% }) %>
<% } %>
<% if (menuData.tree && menuData.tree.length > 0) { %>
<% renderMenu(menuData.tree) %>
<% } %>
</ul>
<% if (!menuData.tree || menuData.tree.length === 0) { %>
<div class="text-center py-5">
<img src="/assets/img/icon/empty-state.svg" alt="Empty" style="width: 120px; opacity: 0.5;" class="mb-3">
<p class="text-muted">No menu items found. Start by adding a root menu.</p>
</div>
<% } %>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
console.log('Menu Tab JS Initialized');
initSortable();
function initSortable() {
const menuGroups = document.querySelectorAll('.menu-group');
menuGroups.forEach(group => {
new Sortable(group, {
group: 'nested-menu',
animation: 150,
fallbackOnBody: true,
swapThreshold: 0.65,
handle: '.menu-drag-handle',
ghostClass: 'menu-ghost',
chosenClass: 'menu-chosen',
dragClass: 'menu-dragging',
onEnd: function (evt) {
console.log('Drag ended', evt);
// Highlight that changes need saving
const saveBtn = document.getElementById('saveHeaderBtn');
if (saveBtn && typeof window.markHeaderChanged === 'function') {
window.markHeaderChanged();
}
}
});
});
}
// Bind Edit/Add/Delete buttons
bindMenuActions();
function bindMenuActions() {
// Use event delegation for better performance and to handle re-rendered items if any
const container = document.getElementById('nestedMenuContainer');
if (container) {
container.addEventListener('click', function(e) {
const editBtn = e.target.closest('.btn-edit-menu');
if (editBtn) {
try {
const item = JSON.parse(editBtn.dataset.item);
console.log('=== TRACE: Edit Menu Clicked ===', item);
prepareEditMenu(item);
} catch (e) {
console.error('Error parsing menu item data:', e);
}
return;
}
const addChildBtn = e.target.closest('.btn-add-child');
if (addChildBtn) {
const pid = addChildBtn.dataset.id;
console.log('=== TRACE: Add Child Clicked ===', { pid });
if (typeof prepareAddChild === 'function') {
prepareAddChild(pid);
} else {
console.error('prepareAddChild function not found');
}
return;
}
const deleteBtn = e.target.closest('.btn-delete-menu');
if (deleteBtn) {
const id = deleteBtn.dataset.id;
const title = deleteBtn.dataset.title;
console.log('=== TRACE: Delete Menu Clicked ===', { id, title });
if (confirm(`Are you sure you want to delete "${title}" and all its sub-menu items?`)) {
const form = document.getElementById('delete-form-' + id);
console.log('=== TRACE: Submitting Delete Form ===', form ? form.action : 'FORM NOT FOUND');
if (form) form.submit();
}
return;
}
const toggleBtn = e.target.closest('.btn-toggle-nested');
if (toggleBtn) {
const wrapper = toggleBtn.closest('.menu-item-wrapper');
const nestedUl = wrapper.querySelector('.nested-list');
const icon = toggleBtn.querySelector('i');
if (nestedUl) {
const isCollapsed = nestedUl.classList.toggle('collapsed');
if (isCollapsed) {
icon.style.transform = 'rotate(-90deg)';
} else {
icon.style.transform = 'rotate(0deg)';
}
}
}
});
}
}
});
function prepareAddMenu() {
console.log('=== TRACE: prepareAddMenu Called ===');
const form = document.getElementById('menuForm');
if (!form) return;
form.action = '/admin/header/menu/create';
document.getElementById('modalTitle').innerText = 'Add Root Menu';
document.getElementById('menuId').value = '';
document.getElementById('parentId').value = '';
document.getElementById('formTitle').value = '';
document.getElementById('formUrl').value = '';
document.getElementById('formOrder').value = '0';
document.getElementById('formStatus').value = 'active';
document.getElementById('typeInternal').checked = true;
const modalElement = document.getElementById('modalAddMenu');
const modal = bootstrap.Modal.getOrCreateInstance(modalElement);
modal.show();
}
function prepareAddChild(parentId) {
prepareAddMenu();
document.getElementById('parentId').value = parentId;
document.getElementById('modalTitle').innerText = 'Add Sub-Menu Item';
}
function prepareEditMenu(item) {
const form = document.getElementById('menuForm');
if (!form) return;
form.action = '/admin/header/menu/update/' + item._id;
document.getElementById('modalTitle').innerText = 'Edit Menu Item';
document.getElementById('menuId').value = item._id;
document.getElementById('parentId').value = item.parentId || '';
document.getElementById('formTitle').value = item.title;
document.getElementById('formUrl').value = item.url;
document.getElementById('formOrder').value = item.order;
document.getElementById('formStatus').value = item.status;
if (item.type === 'external') {
document.getElementById('typeExternal').checked = true;
} else {
document.getElementById('typeInternal').checked = true;
}
const modalElement = document.getElementById('modalAddMenu');
const modal = bootstrap.Modal.getOrCreateInstance(modalElement);
modal.show();
}
function collectMenuData() {
const items = [];
function traverse(element, parentId = null) {
const children = element.children;
for (let i = 0; i < children.length; i++) {
const li = children[i];
if (li.tagName !== 'LI') continue;
const id = li.dataset.id;
if (!id) continue;
items.push({
id: id,
order: i + 1,
parentId: parentId === 'root' ? null : parentId
});
const subUl = li.querySelector('.menu-group');
if (subUl) {
traverse(subUl, id);
}
}
}
const rootUl = document.getElementById('menuRoot');
if (rootUl) traverse(rootUl, 'root');
return items;
}
window.saveMenuChanges = function(showToastFlag = true) {
return new Promise((resolve, reject) => {
console.log('=== TRACE: saveMenuChanges Called (Reorder) ===');
const items = collectMenuData();
if (items.length === 0) {
console.warn('No menu items found to reorder');
return resolve({ success: true, message: 'No items' });
}
const saveBtn = document.getElementById('saveHeaderBtn');
const originalHtml = saveBtn ? saveBtn.innerHTML : '';
// Only manage button state if this is a direct call (not unified save)
const manageButton = showToastFlag && saveBtn;
if (manageButton) {
saveBtn.disabled = true;
saveBtn.innerHTML = '<i class="fas fa-spinner fa-spin me-1"></i>Saving Menu...';
}
fetch('/admin/header/menu/reorder', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ items: items })
})
.then(response => response.json())
.then(data => {
if (data.success) {
if (showToastFlag) showNotification('Menu structure saved', 'success');
resolve(data);
} else {
reject(new Error(data.message));
}
})
.catch(error => reject(error))
.finally(() => {
if (manageButton) {
saveBtn.disabled = false;
saveBtn.innerHTML = originalHtml;
}
});
});
};
window.collectMenuData = collectMenuData;
// Global reset fallback
window.prepareAddMenu = prepareAddMenu;
</script>
<!-- Redundant buttons removed to use global buttons in index.ejs -->
<style>
.nested-menu-container {
padding: var(--spacing-3);
}
.menu-group {
min-height: 10px;
}
.menu-item-row {
transition: var(--transition-base);
}
.menu-drag-handle { cursor: grab; padding: 5px; }
.menu-drag-handle:active { cursor: grabbing; }
/* SortableJS Classes mapped to our variables */
.menu-ghost {
opacity: 0.4;
border: 2px dashed var(--primary-color) !important;
}
.menu-chosen {
background-color: var(--primary-soft) !important;
}
.menu-dragging {
opacity: 0.9;
}
/* Collapse/Expand Styles */
.nested-list {
overflow: hidden;
transition: all 0.3s ease;
max-height: 2000px; /* Large enough for nested items */
}
.nested-list.collapsed {
max-height: 0;
margin-top: 0 !important;
opacity: 0;
pointer-events: none;
}
.btn-toggle-nested i {
transition: transform 0.2s ease;
}
.transition-base {
transition: all 0.2s ease-in-out;
}
</style>

View File

@@ -11,7 +11,7 @@
</div>
<div class="row align-items-center">
<div class="col-lg-6">
<div class="col-lg-6 p-5">
<h1 class="fw-bold mb-4 text-white">API Management</h1>
<p class="lead mb-4 text-white-50">Simple dashboard to control your APIs</p>
<div class="d-flex gap-3">

View File

@@ -120,6 +120,39 @@
<!-- Bootstrap JS -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/js/bootstrap.bundle.min.js"></script>
<script>
// Global function to clean up any stuck modal backdrops
function forceCleanupModals() {
document.body.classList.remove('modal-open');
document.body.style.overflow = '';
document.body.style.paddingRight = '';
const backdrops = document.querySelectorAll('.modal-backdrop, .overlay, .loading');
if (backdrops.length > 0) {
console.warn('Force removing stuck backdrops:', backdrops.length);
backdrops.forEach(el => el.remove());
}
}
// Automatically clean up on every hide event
document.addEventListener('hidden.bs.modal', function() {
// Wait a tiny bit for the animation to finish
setTimeout(forceCleanupModals, 100);
});
// Watchdog: Check if backdrops exist without a visible modal every 2 seconds
setInterval(() => {
const visibleModals = document.querySelectorAll('.modal.show');
if (visibleModals.length === 0) {
const backdrops = document.querySelectorAll('.modal-backdrop');
if (backdrops.length > 0) {
forceCleanupModals();
}
}
}, 2000);
// Clean up on page load
window.addEventListener('load', forceCleanupModals);
</script>
<%- script %>
</body>

View File

@@ -12,16 +12,18 @@
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/css/bootstrap.min.css" rel="stylesheet" />
<!-- Font Awesome -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" />
<!-- Custom CSS -->
<style>
:root {
--primary-color: #bc9f69;
--primary-light: #bba57c;
--primary-dark: #be9d5f;
--secondary-color: #f5f5e8;
--text-light: #black;
}
<!-- Global CSS Variables -->
<link rel="stylesheet" href="/assets/css/variables.css" />
<!-- CMS Component System -->
<link rel="stylesheet" href="/assets/css/components/button.css" />
<link rel="stylesheet" href="/assets/css/components/card.css" />
<link rel="stylesheet" href="/assets/css/components/form.css" />
<link rel="stylesheet" href="/assets/css/components/modal.css" />
<link rel="stylesheet" href="/assets/css/components/table.css" />
<!-- Layout Styles -->
<link rel="stylesheet" href="/assets/css/layout.css" />
<style>
body {
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
position: relative;
@@ -864,16 +866,22 @@
<!-- Bootstrap JS -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/js/bootstrap.bundle.min.js"></script>
<!-- Axios for API calls -->
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
<!-- SortableJS for drag and drop -->
<script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.0/Sortable.min.js"></script>
<!-- Toast JS -->
<script src="/js/toast.js"></script>
<!-- Flash Handler JS -->
<script src="/js/flash-handler.js"></script>
<!-- Custom JS -->
<!-- Custom JS Utilities -->
<script src="/js/main.js"></script>
<!-- Custom modal -->
<!-- Custom modal enhancement -->
<script src="/js/custom-modal.js"></script>
<script>