diff --git a/assets/css/components/button.css b/assets/css/components/button.css new file mode 100644 index 0000000..cd54ae7 --- /dev/null +++ b/assets/css/components/button.css @@ -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; } diff --git a/assets/css/components/card.css b/assets/css/components/card.css new file mode 100644 index 0000000..4765955 --- /dev/null +++ b/assets/css/components/card.css @@ -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); +} diff --git a/assets/css/components/form.css b/assets/css/components/form.css new file mode 100644 index 0000000..069efd1 --- /dev/null +++ b/assets/css/components/form.css @@ -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); +} diff --git a/assets/css/components/modal.css b/assets/css/components/modal.css new file mode 100644 index 0000000..63b0062 --- /dev/null +++ b/assets/css/components/modal.css @@ -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; +} diff --git a/assets/css/components/table.css b/assets/css/components/table.css new file mode 100644 index 0000000..f77f7a5 --- /dev/null +++ b/assets/css/components/table.css @@ -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); } diff --git a/assets/css/layout.css b/assets/css/layout.css new file mode 100644 index 0000000..4b85907 --- /dev/null +++ b/assets/css/layout.css @@ -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); +} diff --git a/assets/css/variables.css b/assets/css/variables.css new file mode 100644 index 0000000..568b5b9 --- /dev/null +++ b/assets/css/variables.css @@ -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; +} diff --git a/controllers/headerController.js b/controllers/headerController.js index 80dc130..85a0d4d 100644 --- a/controllers/headerController.js +++ b/controllers/headerController.js @@ -1,4 +1,24 @@ const Header = require("../models/header"); +const HeaderMenu = require("../models/HeaderMenu"); + +/** + * 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) + ); + + 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 branch.sort((a, b) => a.order - b.order); +}; // Admin: Render header management page exports.index = async (req, res) => { @@ -30,11 +50,23 @@ exports.index = async (req, res) => { logo: "", }; + const activeTab = req.query.tab || "topbar"; + + // 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) + }; + res.render("admin/header/index", { layout: "layouts/main", title: "Header Management", - user: req.session.user, + user: req.session.user || null, data: data, + activeTab: activeTab, + menuData: menuData }); } catch (error) { console.error("Error loading header management:", error); diff --git a/controllers/headerMenuController.js b/controllers/headerMenuController.js new file mode 100644 index 0000000..1ab91b5 --- /dev/null +++ b/controllers/headerMenuController.js @@ -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 }); + } +}; diff --git a/data/header-menu.json b/data/header-menu.json new file mode 100644 index 0000000..496291b --- /dev/null +++ b/data/header-menu.json @@ -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": [] + } +] diff --git a/data/header.json b/data/header.json index 021e6d1..925b0b4 100644 --- a/data/header.json +++ b/data/header.json @@ -48,95 +48,5 @@ "workingHours": "Mod-Friday, 09am - 05pm", "phone": "+09 378 357 5222" } - }, - "menu": [ - { - "label": "Home", - "href": "/", - "children": [] - }, - { - "label": "About Us", - "href": "/about", - "children": [] - }, - { - "label": "Pages", - "href": "#", - "children": [ - { - "label": "Service", - "href": "/service", - "children": [ - { - "label": "Service", - "href": "/service" - }, - { - "label": "Service Details", - "href": "/service-details" - } - ] - }, - { - "label": "Country List", - "href": "/country-list", - "children": [ - { - "label": "Country List", - "href": "/country-list" - }, - { - "label": "Country Details", - "href": "/country-details" - } - ] - }, - { - "label": "Our Pricing", - "href": "/pricing" - }, - { - "label": "Appointment", - "href": "/appointment" - } - ] - }, - { - "label": "VISA", - "href": "#", - "children": [ - { - "label": "Visa List", - "href": "/visa-list" - }, - { - "label": "Visa Details", - "href": "/visa-details" - } - ] - }, - { - "label": "Blog", - "href": "#", - "children": [ - { - "label": "Blog Grid", - "href": "/blog-grid" - }, - { - "label": "Blog Standard", - "href": "/blog" - }, - { - "label": "Blog Details", - "href": "/blog-details" - } - ] - }, - { - "label": "Contact Us", - "href": "/contact" - } - ] + } } diff --git a/models/HeaderMenu.js b/models/HeaderMenu.js new file mode 100644 index 0000000..b5212a8 --- /dev/null +++ b/models/HeaderMenu.js @@ -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); diff --git a/public/images/Logo_round.jpg b/public/images/Logo_round.jpg new file mode 100644 index 0000000..49125be Binary files /dev/null and b/public/images/Logo_round.jpg differ diff --git a/public/img/Logo_round.jpg b/public/img/Logo_round.jpg new file mode 100644 index 0000000..49125be Binary files /dev/null and b/public/img/Logo_round.jpg differ diff --git a/public/js/custom-modal.js b/public/js/custom-modal.js new file mode 100644 index 0000000..4875e4f --- /dev/null +++ b/public/js/custom-modal.js @@ -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); diff --git a/public/js/flash-handler.js b/public/js/flash-handler.js new file mode 100644 index 0000000..40134da --- /dev/null +++ b/public/js/flash-handler.js @@ -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); + } + } +}); diff --git a/public/js/main.js b/public/js/main.js new file mode 100644 index 0000000..9f66ccb --- /dev/null +++ b/public/js/main.js @@ -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 = '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; + } + } +}; diff --git a/public/js/toast.js b/public/js/toast.js new file mode 100644 index 0000000..e08ab91 --- /dev/null +++ b/public/js/toast.js @@ -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 = ` +
/api/camp-location/api/blog