1235 lines
40 KiB
PHP
1235 lines
40 KiB
PHP
<?= $this->extend('layout/management_layout') ?>
|
|
<?= $this->section('content') ?>
|
|
|
|
<style>
|
|
.nav-builder-page {
|
|
max-width: 1380px;
|
|
}
|
|
|
|
.nav-builder-shell {
|
|
display: grid;
|
|
grid-template-columns: minmax(0, 1.15fr) minmax(340px, .85fr);
|
|
gap: 1rem;
|
|
}
|
|
|
|
.nav-builder-panel {
|
|
background: #fff;
|
|
border: 1px solid rgba(15, 23, 42, .08);
|
|
border-radius: .5rem;
|
|
box-shadow: 0 8px 20px rgba(15, 23, 42, .04);
|
|
}
|
|
|
|
.nav-builder-panel-header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: .75rem;
|
|
padding: 1rem 1.125rem;
|
|
border-bottom: 1px solid rgba(15, 23, 42, .08);
|
|
}
|
|
|
|
.nav-builder-panel-body {
|
|
padding: 1rem;
|
|
}
|
|
|
|
.nav-tree {
|
|
min-height: 360px;
|
|
}
|
|
|
|
.nav-tree-empty {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
min-height: 260px;
|
|
border: 1px dashed rgba(100, 116, 139, .45);
|
|
border-radius: .5rem;
|
|
color: #64748b;
|
|
background: #f8fafc;
|
|
}
|
|
|
|
.nav-drop-zone {
|
|
height: .55rem;
|
|
border-radius: 999px;
|
|
margin: .125rem 0;
|
|
transition: background-color .15s ease, height .15s ease;
|
|
}
|
|
|
|
.nav-drop-zone.is-over {
|
|
height: 1.2rem;
|
|
background: rgba(13, 110, 253, .22);
|
|
}
|
|
|
|
.nav-item-row {
|
|
display: grid;
|
|
grid-template-columns: 2.25rem minmax(0, 1fr) auto;
|
|
align-items: center;
|
|
gap: .65rem;
|
|
min-height: 3.25rem;
|
|
padding: .55rem .7rem;
|
|
border: 1px solid rgba(15, 23, 42, .1);
|
|
border-radius: .45rem;
|
|
background: #fff;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.nav-item-row:hover {
|
|
border-color: rgba(13, 110, 253, .32);
|
|
background: #f8fbff;
|
|
}
|
|
|
|
.nav-item-row.is-selected {
|
|
border-color: rgba(13, 110, 253, .75);
|
|
box-shadow: 0 0 0 .18rem rgba(13, 110, 253, .12);
|
|
}
|
|
|
|
.nav-item-row.is-dragging {
|
|
opacity: .52;
|
|
}
|
|
|
|
.nav-item-handle {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 2.25rem;
|
|
height: 2.25rem;
|
|
border: 0;
|
|
border-radius: .4rem;
|
|
color: #64748b;
|
|
background: #f1f5f9;
|
|
cursor: grab;
|
|
}
|
|
|
|
.nav-item-main {
|
|
min-width: 0;
|
|
}
|
|
|
|
.nav-item-title {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: .45rem;
|
|
min-width: 0;
|
|
font-weight: 600;
|
|
color: #0f172a;
|
|
}
|
|
|
|
.nav-item-title span {
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.nav-item-meta {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: .45rem;
|
|
margin-top: .15rem;
|
|
color: #64748b;
|
|
font-size: .825rem;
|
|
min-width: 0;
|
|
}
|
|
|
|
.nav-item-meta .nav-url {
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.nav-item-actions {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: .35rem;
|
|
}
|
|
|
|
.nav-item-toggle {
|
|
width: 2rem;
|
|
height: 2rem;
|
|
padding: 0;
|
|
}
|
|
|
|
.nav-child-zone {
|
|
margin-top: .35rem;
|
|
margin-left: 2.9rem;
|
|
padding: .45rem;
|
|
border: 1px dashed transparent;
|
|
border-radius: .45rem;
|
|
}
|
|
|
|
.nav-child-zone.is-over {
|
|
border-color: rgba(13, 110, 253, .45);
|
|
background: rgba(13, 110, 253, .07);
|
|
}
|
|
|
|
.nav-children {
|
|
margin-left: 1.25rem;
|
|
padding-left: .85rem;
|
|
border-left: 2px solid rgba(148, 163, 184, .35);
|
|
}
|
|
|
|
.nav-role-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
|
gap: .5rem .75rem;
|
|
}
|
|
|
|
.nav-role-grid .form-check {
|
|
min-width: 0;
|
|
}
|
|
|
|
.nav-builder-status {
|
|
min-height: 1.5rem;
|
|
color: #64748b;
|
|
font-size: .9rem;
|
|
}
|
|
|
|
.role-access-table td {
|
|
vertical-align: top;
|
|
}
|
|
|
|
.role-access-panel .nav-builder-panel-header,
|
|
.role-access-panel .role-access-table thead,
|
|
.role-access-panel .role-access-table th {
|
|
position: static !important;
|
|
top: auto !important;
|
|
z-index: auto !important;
|
|
}
|
|
|
|
.role-access-pages {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: .35rem;
|
|
}
|
|
|
|
.role-access-page {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: .35rem;
|
|
max-width: 280px;
|
|
padding: .25rem .45rem;
|
|
border: 1px solid rgba(15, 23, 42, .08);
|
|
border-radius: .35rem;
|
|
background: #f8fafc;
|
|
color: #334155;
|
|
font-size: .82rem;
|
|
cursor: grab;
|
|
}
|
|
|
|
.role-access-page.is-updating {
|
|
opacity: .55;
|
|
pointer-events: none;
|
|
}
|
|
|
|
.role-access-page span {
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.role-access-remove {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 1.15rem;
|
|
height: 1.15rem;
|
|
flex: 0 0 auto;
|
|
border: 0;
|
|
border-radius: 999px;
|
|
background: transparent;
|
|
color: #64748b;
|
|
padding: 0;
|
|
}
|
|
|
|
.role-access-remove:hover {
|
|
background: #e2e8f0;
|
|
color: #b42318;
|
|
}
|
|
|
|
.role-access-drop.is-over td {
|
|
background: rgba(13, 110, 253, .08);
|
|
}
|
|
|
|
@media (max-width: 992px) {
|
|
.nav-builder-shell {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
}
|
|
</style>
|
|
|
|
<div class="container-fluid nav-builder-page py-4"
|
|
data-nav-endpoint="<?= site_url('api/nav-builder') ?>"
|
|
data-delete-base="<?= site_url('nav-builder/delete') ?>"
|
|
data-reorder-endpoint="<?= site_url('nav-builder/reorder') ?>"
|
|
data-role-access-endpoint="<?= site_url('nav-builder/role-access') ?>">
|
|
<div class="d-flex flex-wrap align-items-center justify-content-between gap-3 mb-3">
|
|
<div>
|
|
<h3 class="mb-1">Navbar Builder</h3>
|
|
<div class="text-muted">Drag items to reorder them or drop one onto another item to make it a child.</div>
|
|
</div>
|
|
<div class="d-flex gap-2">
|
|
<button class="btn btn-outline-secondary" type="button" id="refreshNavButton">
|
|
<i class="bi bi-arrow-clockwise me-1"></i>Refresh
|
|
</button>
|
|
<button class="btn btn-primary" type="button" id="saveOrderButton" disabled>
|
|
<i class="bi bi-save me-1"></i>Save order
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<?php if (session()->getFlashdata('success')): ?>
|
|
<div class="alert alert-success"><?= esc(session()->getFlashdata('success')) ?></div>
|
|
<?php endif; ?>
|
|
<?php if (session()->getFlashdata('error')): ?>
|
|
<div class="alert alert-danger"><?= esc(session()->getFlashdata('error')) ?></div>
|
|
<?php endif; ?>
|
|
|
|
<div class="nav-builder-shell">
|
|
<section class="nav-builder-panel">
|
|
<div class="nav-builder-panel-header">
|
|
<div>
|
|
<h5 class="mb-0">Menu Structure</h5>
|
|
<div class="nav-builder-status" id="navBuilderStatus">Loading menu...</div>
|
|
</div>
|
|
<button class="btn btn-sm btn-outline-primary" type="button" id="newItemButton">
|
|
<i class="bi bi-plus-lg me-1"></i>New item
|
|
</button>
|
|
<button class="btn btn-sm btn-outline-secondary" type="button" id="expandAllButton">
|
|
<i class="bi bi-arrows-expand me-1"></i>Expand all
|
|
</button>
|
|
<button class="btn btn-sm btn-outline-secondary" type="button" id="collapseAllButton">
|
|
<i class="bi bi-arrows-collapse me-1"></i>Collapse all
|
|
</button>
|
|
</div>
|
|
<div class="nav-builder-panel-body">
|
|
<div class="nav-tree" id="navTree" aria-live="polite"></div>
|
|
</div>
|
|
</section>
|
|
|
|
<aside class="nav-builder-panel">
|
|
<div class="nav-builder-panel-header">
|
|
<div>
|
|
<h5 class="mb-0" id="editorTitle">New Item</h5>
|
|
<div class="text-muted small">Edit the selected menu item.</div>
|
|
</div>
|
|
</div>
|
|
<div class="nav-builder-panel-body">
|
|
<form method="post" action="<?= site_url('nav-builder/save') ?>" id="navBuilderForm">
|
|
<?= csrf_field() ?>
|
|
<input type="hidden" name="id" id="item_id">
|
|
<input type="hidden" name="sort_order" id="sort_order" value="0">
|
|
|
|
<div class="mb-3">
|
|
<label class="form-label" for="label">Label</label>
|
|
<input class="form-control" name="label" id="label" required>
|
|
</div>
|
|
|
|
<div class="mb-3">
|
|
<label class="form-label" for="url">URL</label>
|
|
<select class="form-select" name="url" id="url">
|
|
<option value="">No link</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div class="mb-3">
|
|
<label class="form-label" for="menu_parent_id">Parent</label>
|
|
<select name="menu_parent_id" id="menu_parent_id" class="form-select">
|
|
<option value="">Top level</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div class="row">
|
|
<div class="col-md-6 mb-3">
|
|
<label class="form-label" for="target">Target</label>
|
|
<select name="target" id="target" class="form-select">
|
|
<option value="">Default</option>
|
|
<option value="_self">Same tab</option>
|
|
<option value="_blank">New tab</option>
|
|
</select>
|
|
</div>
|
|
<div class="col-md-6 mb-3 d-flex align-items-end">
|
|
<div class="form-check form-switch">
|
|
<input class="form-check-input" type="checkbox" name="is_enabled" id="is_enabled" checked>
|
|
<label class="form-check-label" for="is_enabled">Enabled</label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="mb-3">
|
|
<label class="form-label">Visible to Roles</label>
|
|
<div class="nav-role-grid" id="roles"></div>
|
|
</div>
|
|
|
|
<div class="d-flex flex-wrap gap-2">
|
|
<button class="btn btn-primary" type="submit">
|
|
<i class="bi bi-check-lg me-1"></i>Save item
|
|
</button>
|
|
<button class="btn btn-outline-secondary" type="button" id="resetFormButton">Clear</button>
|
|
<a class="btn btn-outline-danger d-none" href="#" id="deleteItemButton">
|
|
<i class="bi bi-trash me-1"></i>Delete
|
|
</a>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</aside>
|
|
</div>
|
|
|
|
<section class="nav-builder-panel role-access-panel mt-3">
|
|
<div class="nav-builder-panel-header">
|
|
<div>
|
|
<h5 class="mb-0">Role Access</h5>
|
|
<div class="text-muted small">Enabled menu pages assigned to each role.</div>
|
|
</div>
|
|
<input class="form-control form-control-sm" id="roleAccessSearch" type="search" placeholder="Filter roles or pages" style="max-width: 260px;">
|
|
</div>
|
|
<div class="nav-builder-panel-body">
|
|
<div class="table-responsive">
|
|
<table class="table table-sm table-hover align-middle role-access-table no-mgmt-sticky mb-0" data-no-mgmt-sticky>
|
|
<thead>
|
|
<tr>
|
|
<th style="width: 220px;">Role</th>
|
|
<th style="width: 90px;">Pages</th>
|
|
<th>Access</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="roleAccessBody">
|
|
<tr>
|
|
<td colspan="3" class="text-muted text-center">Loading access...</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
|
|
<?= $this->endSection() ?>
|
|
|
|
<?= $this->section('scripts') ?>
|
|
<script>
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
const container = document.querySelector('[data-nav-endpoint]');
|
|
if (!container) return;
|
|
|
|
const navEndpoint = container.dataset.navEndpoint;
|
|
const reorderEndpoint = container.dataset.reorderEndpoint;
|
|
const roleAccessEndpoint = container.dataset.roleAccessEndpoint;
|
|
const deleteBase = (container.dataset.deleteBase || '').replace(/\/+$/, '');
|
|
|
|
const treeEl = document.getElementById('navTree');
|
|
const statusEl = document.getElementById('navBuilderStatus');
|
|
const form = document.getElementById('navBuilderForm');
|
|
const saveOrderButton = document.getElementById('saveOrderButton');
|
|
const refreshButton = document.getElementById('refreshNavButton');
|
|
const newItemButton = document.getElementById('newItemButton');
|
|
const expandAllButton = document.getElementById('expandAllButton');
|
|
const collapseAllButton = document.getElementById('collapseAllButton');
|
|
const resetButton = document.getElementById('resetFormButton');
|
|
const deleteButton = document.getElementById('deleteItemButton');
|
|
const parentSelect = document.getElementById('menu_parent_id');
|
|
const urlSelect = document.getElementById('url');
|
|
const rolesEl = document.getElementById('roles');
|
|
const editorTitle = document.getElementById('editorTitle');
|
|
const roleAccessBody = document.getElementById('roleAccessBody');
|
|
const roleAccessSearch = document.getElementById('roleAccessSearch');
|
|
|
|
let items = [];
|
|
let roles = [];
|
|
let routeOptions = [];
|
|
let selectedId = null;
|
|
let draggedId = null;
|
|
let isDirty = false;
|
|
let collapsedIds = new Set();
|
|
let accessFilter = '';
|
|
let draggedAccess = null;
|
|
|
|
const setStatus = (message) => {
|
|
statusEl.textContent = message;
|
|
};
|
|
|
|
const setDirty = (dirty) => {
|
|
isDirty = dirty;
|
|
saveOrderButton.disabled = !dirty;
|
|
saveOrderButton.classList.toggle('btn-warning', dirty);
|
|
saveOrderButton.classList.toggle('btn-primary', !dirty);
|
|
if (dirty) {
|
|
setStatus('Unsaved order changes');
|
|
}
|
|
};
|
|
|
|
const updateCsrf = (csrf) => {
|
|
if (!csrf || !csrf.name || !csrf.hash) return;
|
|
const tokenInput = form.querySelector(`input[type="hidden"][name="${csrf.name}"]`);
|
|
if (tokenInput) {
|
|
tokenInput.value = csrf.hash;
|
|
}
|
|
};
|
|
|
|
const getItem = (id) => items.find((item) => Number(item.id) === Number(id));
|
|
|
|
const getChildren = (parentId) => items
|
|
.filter((item) => {
|
|
const currentParent = item.parent_id === null || item.parent_id === undefined ? null : Number(item.parent_id);
|
|
return currentParent === parentId;
|
|
})
|
|
.sort((a, b) => {
|
|
const orderDiff = Number(a.order || 0) - Number(b.order || 0);
|
|
if (orderDiff !== 0) return orderDiff;
|
|
return String(a.label || '').localeCompare(String(b.label || ''));
|
|
});
|
|
|
|
const getParentIdsWithChildren = () => items
|
|
.filter((item) => getChildren(Number(item.id)).length > 0)
|
|
.map((item) => Number(item.id));
|
|
|
|
const collapseAll = () => {
|
|
collapsedIds = new Set(getParentIdsWithChildren());
|
|
renderTree();
|
|
};
|
|
|
|
const expandAll = () => {
|
|
collapsedIds.clear();
|
|
renderTree();
|
|
};
|
|
|
|
const toggleCollapsed = (id) => {
|
|
const numericId = Number(id);
|
|
if (collapsedIds.has(numericId)) {
|
|
collapsedIds.delete(numericId);
|
|
} else {
|
|
collapsedIds.add(numericId);
|
|
}
|
|
renderTree();
|
|
};
|
|
|
|
const flattenVisibleTree = (parentId = null, depth = 0, rows = []) => {
|
|
getChildren(parentId).forEach((item) => {
|
|
rows.push({ ...item, depth });
|
|
flattenVisibleTree(Number(item.id), depth + 1, rows);
|
|
});
|
|
return rows;
|
|
};
|
|
|
|
const isDescendant = (candidateId, possibleParentId) => {
|
|
let current = getItem(possibleParentId);
|
|
while (current) {
|
|
if (Number(current.id) === Number(candidateId)) {
|
|
return true;
|
|
}
|
|
current = current.parent_id ? getItem(current.parent_id) : null;
|
|
}
|
|
return false;
|
|
};
|
|
|
|
const normalizeSiblingOrders = () => {
|
|
const parentIds = new Set([null]);
|
|
items.forEach((item) => {
|
|
parentIds.add(item.parent_id === null || item.parent_id === undefined ? null : Number(item.parent_id));
|
|
});
|
|
|
|
parentIds.forEach((parentId) => {
|
|
getChildren(parentId).forEach((item, index) => {
|
|
item.order = (index + 1) * 10;
|
|
});
|
|
});
|
|
};
|
|
|
|
const nextOrderForParent = (parentId) => {
|
|
const siblings = getChildren(parentId);
|
|
if (!siblings.length) {
|
|
return 10;
|
|
}
|
|
|
|
return Math.max(...siblings.map((item) => Number(item.order || 0))) + 10;
|
|
};
|
|
|
|
const moveItem = (movingId, targetParentId, beforeId = null) => {
|
|
if (!movingId) return;
|
|
const moving = getItem(movingId);
|
|
if (!moving) return;
|
|
|
|
const normalizedParentId = targetParentId === null || targetParentId === undefined ? null : Number(targetParentId);
|
|
if (normalizedParentId !== null && (Number(movingId) === normalizedParentId || isDescendant(movingId, normalizedParentId))) {
|
|
setStatus('Cannot move an item inside itself.');
|
|
return;
|
|
}
|
|
|
|
moving.parent_id = normalizedParentId;
|
|
if (normalizedParentId !== null) {
|
|
collapsedIds.delete(normalizedParentId);
|
|
}
|
|
normalizeSiblingOrders();
|
|
|
|
const siblings = getChildren(normalizedParentId).filter((item) => Number(item.id) !== Number(movingId));
|
|
const foundIndex = beforeId === null
|
|
? siblings.length
|
|
: siblings.findIndex((item) => Number(item.id) === Number(beforeId));
|
|
const insertIndex = foundIndex < 0 ? siblings.length : foundIndex;
|
|
|
|
siblings.splice(insertIndex, 0, moving);
|
|
siblings.forEach((item, index) => {
|
|
item.order = (index + 1) * 10;
|
|
});
|
|
|
|
selectedId = Number(movingId);
|
|
document.getElementById('sort_order').value = String(moving.order || 0);
|
|
renderTree();
|
|
renderParentOptions();
|
|
renderRoleAccessTable();
|
|
setDirty(true);
|
|
};
|
|
|
|
const clearDropState = () => {
|
|
document.querySelectorAll('.nav-drop-zone.is-over, .nav-child-zone.is-over')
|
|
.forEach((el) => el.classList.remove('is-over'));
|
|
};
|
|
|
|
const attachDropHandlers = (element, onDrop) => {
|
|
element.addEventListener('dragover', (event) => {
|
|
event.preventDefault();
|
|
element.classList.add('is-over');
|
|
});
|
|
element.addEventListener('dragleave', () => {
|
|
element.classList.remove('is-over');
|
|
});
|
|
element.addEventListener('drop', (event) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
clearDropState();
|
|
onDrop();
|
|
});
|
|
};
|
|
|
|
const renderDropZone = (parent, targetParentId, beforeId = null) => {
|
|
const zone = document.createElement('div');
|
|
zone.className = 'nav-drop-zone';
|
|
attachDropHandlers(zone, () => moveItem(draggedId, targetParentId, beforeId));
|
|
parent.appendChild(zone);
|
|
};
|
|
|
|
const renderBranch = (parent, parentId = null) => {
|
|
const children = getChildren(parentId);
|
|
children.forEach((item) => {
|
|
renderDropZone(parent, parentId, Number(item.id));
|
|
|
|
const wrapper = document.createElement('div');
|
|
wrapper.className = 'nav-node';
|
|
|
|
const row = document.createElement('div');
|
|
row.className = 'nav-item-row';
|
|
row.draggable = true;
|
|
row.dataset.itemId = item.id;
|
|
if (Number(item.id) === Number(selectedId)) {
|
|
row.classList.add('is-selected');
|
|
}
|
|
|
|
row.addEventListener('click', () => selectItem(item.id));
|
|
row.addEventListener('dragstart', (event) => {
|
|
draggedId = Number(item.id);
|
|
row.classList.add('is-dragging');
|
|
event.dataTransfer.effectAllowed = 'move';
|
|
event.dataTransfer.setData('text/plain', String(item.id));
|
|
});
|
|
row.addEventListener('dragend', () => {
|
|
draggedId = null;
|
|
row.classList.remove('is-dragging');
|
|
clearDropState();
|
|
});
|
|
|
|
const handle = document.createElement('span');
|
|
handle.className = 'nav-item-handle';
|
|
handle.title = 'Drag';
|
|
handle.innerHTML = '<i class="bi bi-grip-vertical"></i>';
|
|
row.appendChild(handle);
|
|
|
|
const main = document.createElement('div');
|
|
main.className = 'nav-item-main';
|
|
|
|
const title = document.createElement('div');
|
|
title.className = 'nav-item-title';
|
|
title.innerHTML = item.enabled
|
|
? '<i class="bi bi-circle-fill text-success small"></i>'
|
|
: '<i class="bi bi-circle text-muted small"></i>';
|
|
const label = document.createElement('span');
|
|
label.textContent = item.label || '(Untitled)';
|
|
title.appendChild(label);
|
|
main.appendChild(title);
|
|
|
|
const meta = document.createElement('div');
|
|
meta.className = 'nav-item-meta';
|
|
const childCount = getChildren(Number(item.id)).length;
|
|
meta.innerHTML = `<span>${childCount} child${childCount === 1 ? '' : 'ren'}</span>`;
|
|
const url = document.createElement('span');
|
|
url.className = 'nav-url';
|
|
url.textContent = item.url || 'No URL';
|
|
meta.appendChild(url);
|
|
main.appendChild(meta);
|
|
row.appendChild(main);
|
|
|
|
const actions = document.createElement('div');
|
|
actions.className = 'nav-item-actions';
|
|
if (childCount > 0) {
|
|
const toggleButton = document.createElement('button');
|
|
const isCollapsed = collapsedIds.has(Number(item.id));
|
|
toggleButton.type = 'button';
|
|
toggleButton.className = 'btn btn-sm btn-outline-secondary nav-item-toggle';
|
|
toggleButton.title = isCollapsed ? 'Expand' : 'Collapse';
|
|
toggleButton.setAttribute('aria-label', isCollapsed ? 'Expand item' : 'Collapse item');
|
|
toggleButton.innerHTML = isCollapsed
|
|
? '<i class="bi bi-chevron-right"></i>'
|
|
: '<i class="bi bi-chevron-down"></i>';
|
|
toggleButton.addEventListener('click', (event) => {
|
|
event.stopPropagation();
|
|
toggleCollapsed(item.id);
|
|
});
|
|
actions.appendChild(toggleButton);
|
|
}
|
|
|
|
const childDrop = document.createElement('button');
|
|
childDrop.type = 'button';
|
|
childDrop.className = 'btn btn-sm btn-outline-secondary';
|
|
childDrop.title = 'Select';
|
|
childDrop.innerHTML = '<i class="bi bi-pencil"></i>';
|
|
childDrop.addEventListener('click', (event) => {
|
|
event.stopPropagation();
|
|
selectItem(item.id);
|
|
});
|
|
actions.appendChild(childDrop);
|
|
row.appendChild(actions);
|
|
|
|
wrapper.appendChild(row);
|
|
|
|
const childZone = document.createElement('div');
|
|
childZone.className = 'nav-child-zone text-muted small';
|
|
childZone.textContent = childCount > 0 && collapsedIds.has(Number(item.id))
|
|
? `Drop here to place under ${item.label || 'this item'} (${childCount} hidden)`
|
|
: 'Drop here to place under ' + (item.label || 'this item');
|
|
attachDropHandlers(childZone, () => moveItem(draggedId, Number(item.id)));
|
|
wrapper.appendChild(childZone);
|
|
|
|
if (!collapsedIds.has(Number(item.id))) {
|
|
const branch = document.createElement('div');
|
|
branch.className = 'nav-children';
|
|
renderBranch(branch, Number(item.id));
|
|
if (branch.children.length > 0) {
|
|
wrapper.appendChild(branch);
|
|
}
|
|
}
|
|
|
|
parent.appendChild(wrapper);
|
|
});
|
|
|
|
renderDropZone(parent, parentId, null);
|
|
};
|
|
|
|
const renderTree = () => {
|
|
treeEl.innerHTML = '';
|
|
|
|
if (!items.length) {
|
|
const empty = document.createElement('div');
|
|
empty.className = 'nav-tree-empty';
|
|
empty.textContent = 'No menu items yet.';
|
|
treeEl.appendChild(empty);
|
|
attachDropHandlers(empty, () => moveItem(draggedId, null));
|
|
return;
|
|
}
|
|
|
|
renderBranch(treeEl, null);
|
|
const count = items.length;
|
|
if (!isDirty) {
|
|
setStatus(`${count} menu item${count === 1 ? '' : 's'}`);
|
|
}
|
|
};
|
|
|
|
const renderParentOptions = () => {
|
|
const currentValue = parentSelect.value;
|
|
parentSelect.innerHTML = '<option value="">Top level</option>';
|
|
|
|
flattenVisibleTree().forEach((item) => {
|
|
if (selectedId && Number(item.id) === Number(selectedId)) {
|
|
return;
|
|
}
|
|
if (selectedId && isDescendant(selectedId, Number(item.id))) {
|
|
return;
|
|
}
|
|
const option = document.createElement('option');
|
|
option.value = String(item.id);
|
|
option.textContent = `${'-- '.repeat(item.depth || 0)}${item.label || '(Untitled)'}`;
|
|
parentSelect.appendChild(option);
|
|
});
|
|
|
|
if (Array.from(parentSelect.options).some((option) => option.value === currentValue)) {
|
|
parentSelect.value = currentValue;
|
|
}
|
|
};
|
|
|
|
const normalizeRouteValue = (value) => String(value || '').replace(/^\/+/, '');
|
|
|
|
const renderRouteOptions = (selectedUrl = '') => {
|
|
const selectedValue = normalizeRouteValue(selectedUrl);
|
|
urlSelect.innerHTML = '<option value="">No link</option>';
|
|
|
|
const hasSelectedValue = routeOptions.some((route) => normalizeRouteValue(route.value) === selectedValue);
|
|
if (selectedValue && !hasSelectedValue) {
|
|
const currentOption = document.createElement('option');
|
|
currentOption.value = selectedValue;
|
|
currentOption.textContent = `Current custom URL: ${selectedValue}`;
|
|
urlSelect.appendChild(currentOption);
|
|
}
|
|
|
|
routeOptions.forEach((route) => {
|
|
const option = document.createElement('option');
|
|
option.value = normalizeRouteValue(route.value);
|
|
option.textContent = route.needs_params
|
|
? `${route.label} - ${route.value} (needs value)`
|
|
: `${route.label} - ${route.value}`;
|
|
urlSelect.appendChild(option);
|
|
});
|
|
|
|
urlSelect.value = selectedValue;
|
|
};
|
|
|
|
const renderRoles = (selectedIds = []) => {
|
|
const selectedLookup = new Set(selectedIds.map((id) => String(id)));
|
|
rolesEl.innerHTML = '';
|
|
|
|
roles.forEach((role) => {
|
|
const id = `role-${role.id}`;
|
|
const wrapper = document.createElement('div');
|
|
wrapper.className = 'form-check';
|
|
wrapper.innerHTML = `
|
|
<input class="form-check-input" type="checkbox" name="roles[]" id="${id}" value="${role.id}">
|
|
<label class="form-check-label text-truncate" for="${id}"></label>
|
|
`;
|
|
const input = wrapper.querySelector('input');
|
|
const label = wrapper.querySelector('label');
|
|
input.checked = selectedLookup.has(String(role.id));
|
|
label.textContent = role.name || ('Role #' + role.id);
|
|
rolesEl.appendChild(wrapper);
|
|
});
|
|
};
|
|
|
|
const renderRoleAccessTable = () => {
|
|
const normalizedFilter = accessFilter.trim().toLowerCase();
|
|
roleAccessBody.innerHTML = '';
|
|
|
|
const rows = roles.map((role) => {
|
|
const pages = flattenVisibleTree()
|
|
.filter((item) => item.enabled && Array.isArray(item.roles?.ids) && item.roles.ids.map(String).includes(String(role.id)))
|
|
.map((item) => ({
|
|
id: item.id,
|
|
label: item.label || '(Untitled)',
|
|
url: item.url || '',
|
|
depth: item.depth || 0,
|
|
}));
|
|
|
|
return {
|
|
role,
|
|
pages,
|
|
searchText: [
|
|
role.name || '',
|
|
...pages.flatMap((page) => [page.label, page.url]),
|
|
].join(' ').toLowerCase(),
|
|
};
|
|
}).filter((row) => !normalizedFilter || row.searchText.includes(normalizedFilter));
|
|
|
|
if (!rows.length) {
|
|
const emptyRow = document.createElement('tr');
|
|
emptyRow.innerHTML = '<td colspan="3" class="text-muted text-center">No matching role access found.</td>';
|
|
roleAccessBody.appendChild(emptyRow);
|
|
return;
|
|
}
|
|
|
|
rows.forEach((row) => {
|
|
const tr = document.createElement('tr');
|
|
tr.className = 'role-access-drop';
|
|
tr.dataset.roleId = row.role.id;
|
|
|
|
tr.addEventListener('dragover', (event) => {
|
|
if (!draggedAccess || Number(draggedAccess.sourceRoleId) === Number(row.role.id)) return;
|
|
event.preventDefault();
|
|
tr.classList.add('is-over');
|
|
});
|
|
tr.addEventListener('dragleave', () => {
|
|
tr.classList.remove('is-over');
|
|
});
|
|
tr.addEventListener('drop', (event) => {
|
|
event.preventDefault();
|
|
tr.classList.remove('is-over');
|
|
if (!draggedAccess || Number(draggedAccess.sourceRoleId) === Number(row.role.id)) return;
|
|
updateRoleAccess({
|
|
action: 'move',
|
|
navItemId: draggedAccess.navItemId,
|
|
sourceRoleId: draggedAccess.sourceRoleId,
|
|
targetRoleId: row.role.id,
|
|
});
|
|
});
|
|
|
|
const roleCell = document.createElement('td');
|
|
roleCell.className = 'fw-semibold';
|
|
roleCell.textContent = row.role.name || ('Role #' + row.role.id);
|
|
tr.appendChild(roleCell);
|
|
|
|
const countCell = document.createElement('td');
|
|
countCell.textContent = String(row.pages.length);
|
|
tr.appendChild(countCell);
|
|
|
|
const pagesCell = document.createElement('td');
|
|
if (!row.pages.length) {
|
|
pagesCell.innerHTML = '<span class="text-muted">No enabled pages assigned</span>';
|
|
} else {
|
|
const wrap = document.createElement('div');
|
|
wrap.className = 'role-access-pages';
|
|
row.pages.forEach((page) => {
|
|
const badge = document.createElement('span');
|
|
badge.className = 'role-access-page';
|
|
badge.draggable = true;
|
|
badge.dataset.navItemId = page.id;
|
|
badge.dataset.roleId = row.role.id;
|
|
badge.title = page.url ? `${page.label} - ${page.url}` : page.label;
|
|
|
|
badge.addEventListener('dragstart', (event) => {
|
|
draggedAccess = {
|
|
navItemId: page.id,
|
|
sourceRoleId: row.role.id,
|
|
};
|
|
badge.classList.add('is-updating');
|
|
event.dataTransfer.effectAllowed = 'move';
|
|
event.dataTransfer.setData('text/plain', JSON.stringify(draggedAccess));
|
|
});
|
|
badge.addEventListener('dragend', () => {
|
|
draggedAccess = null;
|
|
badge.classList.remove('is-updating');
|
|
document.querySelectorAll('.role-access-drop.is-over').forEach((el) => el.classList.remove('is-over'));
|
|
});
|
|
|
|
const text = document.createElement('span');
|
|
text.textContent = `${'-- '.repeat(page.depth)}${page.label}${page.url ? ` (${page.url})` : ''}`;
|
|
badge.appendChild(text);
|
|
|
|
const removeButton = document.createElement('button');
|
|
removeButton.type = 'button';
|
|
removeButton.className = 'role-access-remove';
|
|
removeButton.title = 'Remove access';
|
|
removeButton.setAttribute('aria-label', 'Remove access');
|
|
removeButton.innerHTML = '<i class="bi bi-x"></i>';
|
|
removeButton.addEventListener('click', (event) => {
|
|
event.stopPropagation();
|
|
updateRoleAccess({
|
|
action: 'remove',
|
|
navItemId: page.id,
|
|
sourceRoleId: row.role.id,
|
|
});
|
|
});
|
|
badge.appendChild(removeButton);
|
|
|
|
wrap.appendChild(badge);
|
|
});
|
|
pagesCell.appendChild(wrap);
|
|
}
|
|
tr.appendChild(pagesCell);
|
|
|
|
roleAccessBody.appendChild(tr);
|
|
});
|
|
};
|
|
|
|
const applyPayload = (payload) => {
|
|
items = Array.isArray(payload?.items) ? payload.items.map(normalizePayloadItem) : [];
|
|
roles = Array.isArray(payload?.roles) ? payload.roles : [];
|
|
routeOptions = Array.isArray(payload?.routeOptions) ? payload.routeOptions : [];
|
|
};
|
|
|
|
const normalizePayloadItem = (item) => ({
|
|
...item,
|
|
id: Number(item.id),
|
|
parent_id: item.parent_id === null || item.parent_id === undefined ? null : Number(item.parent_id),
|
|
order: Number(item.order || 0),
|
|
});
|
|
|
|
const mergeSavedItemFromPayload = (payload, savedId) => {
|
|
roles = Array.isArray(payload?.roles) ? payload.roles : roles;
|
|
routeOptions = Array.isArray(payload?.routeOptions) ? payload.routeOptions : routeOptions;
|
|
|
|
const savedItem = Array.isArray(payload?.items)
|
|
? payload.items.find((item) => Number(item.id) === Number(savedId))
|
|
: null;
|
|
if (!savedItem) return;
|
|
|
|
const normalized = normalizePayloadItem(savedItem);
|
|
const existingIndex = items.findIndex((item) => Number(item.id) === Number(savedId));
|
|
if (existingIndex >= 0) {
|
|
items[existingIndex] = normalized;
|
|
} else {
|
|
items.push(normalized);
|
|
}
|
|
};
|
|
|
|
const renderCurrentState = () => {
|
|
renderRouteOptions(selectedId ? (getItem(selectedId)?.url || '') : '');
|
|
renderRoles(selectedId && getItem(selectedId) && Array.isArray(getItem(selectedId).roles?.ids)
|
|
? getItem(selectedId).roles.ids
|
|
: []);
|
|
renderParentOptions();
|
|
if (selectedId && getItem(selectedId)) {
|
|
parentSelect.value = getItem(selectedId).parent_id ? String(getItem(selectedId).parent_id) : '';
|
|
}
|
|
renderRoleAccessTable();
|
|
renderTree();
|
|
};
|
|
|
|
const resetForm = () => {
|
|
form.reset();
|
|
selectedId = null;
|
|
document.getElementById('item_id').value = '';
|
|
document.getElementById('sort_order').value = String(nextOrderForParent(null));
|
|
renderRouteOptions('');
|
|
parentSelect.value = '';
|
|
editorTitle.textContent = 'New Item';
|
|
deleteButton.classList.add('d-none');
|
|
deleteButton.href = '#';
|
|
renderRoles([]);
|
|
renderParentOptions();
|
|
renderRoleAccessTable();
|
|
renderTree();
|
|
};
|
|
|
|
const selectItem = (id) => {
|
|
const item = getItem(id);
|
|
if (!item) return;
|
|
|
|
selectedId = Number(id);
|
|
editorTitle.textContent = 'Edit Item';
|
|
document.getElementById('item_id').value = item.id || '';
|
|
document.getElementById('label').value = item.label || '';
|
|
renderRouteOptions(item.url || '');
|
|
document.getElementById('sort_order').value = Number(item.order || 0);
|
|
document.getElementById('target').value = item.target || '';
|
|
document.getElementById('is_enabled').checked = !!item.enabled;
|
|
parentSelect.value = item.parent_id ? String(item.parent_id) : '';
|
|
deleteButton.href = `${deleteBase}/${item.id}`;
|
|
deleteButton.classList.remove('d-none');
|
|
renderRoles(Array.isArray(item.roles?.ids) ? item.roles.ids : []);
|
|
renderParentOptions();
|
|
parentSelect.value = item.parent_id ? String(item.parent_id) : '';
|
|
renderRoleAccessTable();
|
|
renderTree();
|
|
};
|
|
|
|
const fetchNavData = () => {
|
|
setStatus('Loading menu...');
|
|
treeEl.innerHTML = '<div class="nav-tree-empty">Loading menu...</div>';
|
|
|
|
return fetch(navEndpoint, {
|
|
headers: { 'Accept': 'application/json' },
|
|
credentials: 'same-origin',
|
|
})
|
|
.then((response) => {
|
|
if (!response.ok) {
|
|
throw new Error(`Request failed with status ${response.status}`);
|
|
}
|
|
return response.json();
|
|
})
|
|
.then((payload) => {
|
|
applyPayload(payload);
|
|
collapsedIds = new Set(getParentIdsWithChildren());
|
|
setDirty(false);
|
|
renderRouteOptions('');
|
|
renderRoles([]);
|
|
renderParentOptions();
|
|
renderRoleAccessTable();
|
|
resetForm();
|
|
})
|
|
.catch((error) => {
|
|
console.error(error);
|
|
treeEl.innerHTML = '<div class="nav-tree-empty text-danger">Failed to load menu items.</div>';
|
|
setStatus('Load failed');
|
|
});
|
|
};
|
|
|
|
const saveOrder = () => {
|
|
normalizeSiblingOrders();
|
|
const payloadRows = flattenVisibleTree();
|
|
const formData = new FormData();
|
|
const csrf = form.querySelector('input[type="hidden"][name]:not(#item_id):not(#sort_order)');
|
|
if (csrf) {
|
|
formData.append(csrf.name, csrf.value);
|
|
}
|
|
|
|
payloadRows.forEach((item, index) => {
|
|
formData.append(`structure[${index}][id]`, item.id);
|
|
formData.append(`structure[${index}][parent_id]`, item.parent_id === null ? '' : item.parent_id);
|
|
formData.append(`structure[${index}][sort_order]`, item.order);
|
|
});
|
|
|
|
saveOrderButton.disabled = true;
|
|
setStatus('Saving order...');
|
|
|
|
fetch(reorderEndpoint, {
|
|
method: 'POST',
|
|
body: formData,
|
|
credentials: 'same-origin',
|
|
headers: { 'Accept': 'application/json' },
|
|
})
|
|
.then((response) => {
|
|
if (!response.ok) {
|
|
throw new Error(`Request failed with status ${response.status}`);
|
|
}
|
|
return response.json();
|
|
})
|
|
.then((payload) => {
|
|
updateCsrf(payload.csrf);
|
|
setDirty(false);
|
|
setStatus('Order saved');
|
|
fetchNavData();
|
|
})
|
|
.catch((error) => {
|
|
console.error(error);
|
|
saveOrderButton.disabled = false;
|
|
setStatus('Order save failed');
|
|
});
|
|
};
|
|
|
|
const saveItem = (event) => {
|
|
event.preventDefault();
|
|
|
|
const submitButton = form.querySelector('button[type="submit"]');
|
|
const previousSelection = selectedId;
|
|
const hadUnsavedOrder = isDirty;
|
|
const formData = new FormData(form);
|
|
|
|
submitButton.disabled = true;
|
|
setStatus('Saving item...');
|
|
|
|
fetch(form.action, {
|
|
method: 'POST',
|
|
body: formData,
|
|
credentials: 'same-origin',
|
|
headers: {
|
|
'Accept': 'application/json',
|
|
'X-Requested-With': 'XMLHttpRequest',
|
|
},
|
|
})
|
|
.then((response) => {
|
|
if (!response.ok) {
|
|
return response.json()
|
|
.catch(() => ({}))
|
|
.then((payload) => {
|
|
throw new Error(payload.message || `Request failed with status ${response.status}`);
|
|
});
|
|
}
|
|
return response.json();
|
|
})
|
|
.then((payload) => {
|
|
updateCsrf(payload.csrf);
|
|
const savedId = Number(payload.id || previousSelection || 0);
|
|
if (payload.payload) {
|
|
if (hadUnsavedOrder) {
|
|
mergeSavedItemFromPayload(payload.payload, savedId);
|
|
} else {
|
|
applyPayload(payload.payload);
|
|
}
|
|
}
|
|
|
|
setDirty(hadUnsavedOrder);
|
|
renderRouteOptions('');
|
|
renderRoles([]);
|
|
renderParentOptions();
|
|
renderRoleAccessTable();
|
|
renderTree();
|
|
|
|
if (savedId > 0 && getItem(savedId)) {
|
|
selectItem(savedId);
|
|
}
|
|
|
|
setStatus(hadUnsavedOrder ? 'Item saved. Order changes still need saving.' : (payload.message || 'Item saved'));
|
|
})
|
|
.catch((error) => {
|
|
console.error(error);
|
|
setStatus(error.message || 'Item save failed');
|
|
})
|
|
.finally(() => {
|
|
submitButton.disabled = false;
|
|
});
|
|
};
|
|
|
|
const updateRoleAccess = ({ action, navItemId, sourceRoleId, targetRoleId = null }) => {
|
|
const formData = new FormData();
|
|
const csrf = form.querySelector('input[type="hidden"][name]:not(#item_id):not(#sort_order)');
|
|
if (csrf) {
|
|
formData.append(csrf.name, csrf.value);
|
|
}
|
|
formData.append('action', action);
|
|
formData.append('nav_item_id', navItemId);
|
|
formData.append('source_role_id', sourceRoleId);
|
|
if (targetRoleId !== null) {
|
|
formData.append('target_role_id', targetRoleId);
|
|
}
|
|
|
|
setStatus(action === 'remove' ? 'Removing role access...' : 'Moving role access...');
|
|
|
|
fetch(roleAccessEndpoint, {
|
|
method: 'POST',
|
|
body: formData,
|
|
credentials: 'same-origin',
|
|
headers: {
|
|
'Accept': 'application/json',
|
|
'X-Requested-With': 'XMLHttpRequest',
|
|
},
|
|
})
|
|
.then((response) => {
|
|
if (!response.ok) {
|
|
return response.json()
|
|
.catch(() => ({}))
|
|
.then((payload) => {
|
|
throw new Error(payload.message || `Request failed with status ${response.status}`);
|
|
});
|
|
}
|
|
return response.json();
|
|
})
|
|
.then((payload) => {
|
|
updateCsrf(payload.csrf);
|
|
if (payload.payload) {
|
|
applyPayload(payload.payload);
|
|
}
|
|
renderCurrentState();
|
|
setStatus(payload.message || 'Role access updated');
|
|
})
|
|
.catch((error) => {
|
|
console.error(error);
|
|
setStatus(error.message || 'Role access update failed');
|
|
renderRoleAccessTable();
|
|
});
|
|
};
|
|
|
|
parentSelect.addEventListener('change', () => {
|
|
const nextParent = parentSelect.value ? Number(parentSelect.value) : null;
|
|
if (!selectedId) {
|
|
document.getElementById('sort_order').value = String(nextOrderForParent(nextParent));
|
|
return;
|
|
}
|
|
|
|
const item = getItem(selectedId);
|
|
if (!item) return;
|
|
moveItem(item.id, nextParent);
|
|
});
|
|
|
|
deleteButton.addEventListener('click', (event) => {
|
|
if (!selectedId || !confirm('Delete this menu item?')) {
|
|
event.preventDefault();
|
|
}
|
|
});
|
|
|
|
form.addEventListener('submit', saveItem);
|
|
saveOrderButton.addEventListener('click', saveOrder);
|
|
refreshButton.addEventListener('click', fetchNavData);
|
|
newItemButton.addEventListener('click', resetForm);
|
|
expandAllButton.addEventListener('click', expandAll);
|
|
collapseAllButton.addEventListener('click', collapseAll);
|
|
resetButton.addEventListener('click', resetForm);
|
|
roleAccessSearch.addEventListener('input', () => {
|
|
accessFilter = roleAccessSearch.value;
|
|
renderRoleAccessTable();
|
|
});
|
|
|
|
fetchNavData();
|
|
});
|
|
</script>
|
|
<?= $this->endSection() ?>
|