add api headermenu and crud management

This commit is contained in:
2026-02-05 00:01:58 +07:00
parent befe6b30aa
commit f25f6b9156
29 changed files with 2058 additions and 634 deletions

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>