453 lines
14 KiB
PHP
453 lines
14 KiB
PHP
<?= $this->extend('layout/management_layout') ?>
|
|
<?= $this->section('content') ?>
|
|
|
|
<!-- DataTables CSS (Bootstrap 5) -->
|
|
<link rel="stylesheet" href="https://cdn.datatables.net/1.13.8/css/dataTables.bootstrap5.min.css">
|
|
<link rel="stylesheet" href="https://cdn.datatables.net/responsive/2.5.0/css/responsive.bootstrap5.min.css">
|
|
<link rel="stylesheet" href="https://cdn.datatables.net/rowgroup/1.4.1/css/rowGroup.bootstrap5.min.css">
|
|
|
|
<style>
|
|
#menuTableWrap {
|
|
position: relative;
|
|
z-index: 2;
|
|
background: #fff;
|
|
}
|
|
|
|
#menuTable.table thead th {
|
|
white-space: nowrap;
|
|
background: #fff;
|
|
position: sticky;
|
|
top: 0;
|
|
z-index: 3;
|
|
}
|
|
|
|
#menuTable.table td,
|
|
#menuTable.table th {
|
|
vertical-align: middle;
|
|
}
|
|
|
|
#menuTable.table tr.odd-row > td {
|
|
background-color: #fff;
|
|
}
|
|
|
|
#menuTable.table tr.even-row > td {
|
|
background-color: #f9fbfd;
|
|
}
|
|
|
|
.badge-role {
|
|
background: #34fd02ff;
|
|
color: #141414ff;
|
|
font-weight: 500;
|
|
}
|
|
|
|
table.dataTable tr.dtrg-group,
|
|
table.dataTable tr.dtrg-group.dtrg-start {
|
|
background: #f3f6f9 !important;
|
|
font-weight: 600;
|
|
color: #34495e;
|
|
}
|
|
|
|
#menuTable_wrapper .dataTables_length,
|
|
#menuTable_wrapper .dataTables_filter {
|
|
margin-bottom: .5rem;
|
|
}
|
|
|
|
#menuTable_wrapper .row {
|
|
align-items: center;
|
|
}
|
|
</style>
|
|
|
|
<div class="container mt-4"
|
|
data-nav-endpoint="<?= site_url('api/nav-builder') ?>"
|
|
data-delete-base="<?= site_url('nav-builder/delete') ?>">
|
|
<h3>Navbar Builder</h3>
|
|
|
|
<?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="container-fluid px-0 mt-3">
|
|
<div class="card mb-4 rounded-0 border-0" id="menuTableCard">
|
|
<div class="card-header">Current Menu</div>
|
|
<div class="card-body">
|
|
<div id="menuTableWrap" class="table-responsive">
|
|
<table id="menuTable" class="table table-striped table-hover align-middle nowrap" style="width:100%">
|
|
<thead>
|
|
<tr>
|
|
<th>Actions</th>
|
|
<th>Label</th>
|
|
<th>URL</th>
|
|
<th>Parent</th>
|
|
<th>Roles</th>
|
|
<th>Order</th>
|
|
<th>Enabled</th>
|
|
<th>Depth</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr>
|
|
<td colspan="8" class="text-center text-muted">Loading menu…</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="row">
|
|
<div class="col-lg-5">
|
|
<div class="card mb-3">
|
|
<div class="card-header">Add / Edit Item</div>
|
|
<div class="card-body">
|
|
<form method="post" action="<?= site_url('nav-builder/save') ?>" id="navBuilderForm">
|
|
<?= csrf_field() ?>
|
|
<input type="hidden" name="id" id="item_id">
|
|
|
|
<div class="mb-2">
|
|
<label class="form-label">Parent</label>
|
|
<select name="menu_parent_id" id="menu_parent_id" class="form-select">
|
|
<option value="">— Top level —</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div class="mb-2">
|
|
<label class="form-label">Label</label>
|
|
<input class="form-control" name="label" id="label" required>
|
|
</div>
|
|
|
|
<div class="mb-2">
|
|
<label class="form-label">URL (relative or absolute)</label>
|
|
<input class="form-control" name="url" id="url" placeholder="e.g. administrator/student_profiles">
|
|
</div>
|
|
|
|
<div class="row">
|
|
<div class="col-md-4 mb-2">
|
|
<label class="form-label">Target</label>
|
|
<select name="target" id="target" class="form-select">
|
|
<option value="">(default)</option>
|
|
<option value="_blank">_blank</option>
|
|
<option value="_self">_self</option>
|
|
</select>
|
|
</div>
|
|
<div class="col-md-4 mb-2">
|
|
<label class="form-label">Order</label>
|
|
<input class="form-control" type="number" name="sort_order" id="sort_order" value="0">
|
|
</div>
|
|
<div class="col-md-4 mb-2 d-flex align-items-end">
|
|
<div class="form-check">
|
|
<input class="form-check-input" type="checkbox" name="is_enabled" id="is_enabled" checked>
|
|
<label class="form-check-label">Enabled</label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="mb-2">
|
|
<label class="form-label">Roles (who can see)</label>
|
|
<select name="roles[]" id="roles" class="form-select" multiple size="6"></select>
|
|
<small class="text-muted">Hold Ctrl/⌘ to select multiple.</small>
|
|
</div>
|
|
|
|
<button class="btn btn-primary" type="submit">Save</button>
|
|
<button class="btn btn-secondary" type="button" id="resetFormButton">Reset</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</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 deleteBase = container.dataset.deleteBase;
|
|
|
|
const tableBody = document.querySelector('#menuTable tbody');
|
|
const parentSelect = document.getElementById('menu_parent_id');
|
|
const rolesSelect = document.getElementById('roles');
|
|
const resetButton = document.getElementById('resetFormButton');
|
|
const form = document.getElementById('navBuilderForm');
|
|
|
|
let items = [];
|
|
let itemsById = new Map();
|
|
|
|
const hasDataTables = () =>
|
|
!!(window.jQuery && window.jQuery.fn && window.jQuery.fn.DataTable);
|
|
|
|
const destroyDataTable = () => {
|
|
if (!hasDataTables()) return;
|
|
if (window.jQuery.fn.DataTable.isDataTable('#menuTable')) {
|
|
window.jQuery('#menuTable').DataTable().clear().destroy();
|
|
}
|
|
};
|
|
|
|
const initDataTable = () => {
|
|
if (!hasDataTables()) return;
|
|
window.jQuery('#menuTable').DataTable({
|
|
responsive: true,
|
|
pageLength: 100,
|
|
order: [
|
|
[3, 'asc'],
|
|
[1, 'asc'],
|
|
],
|
|
stripeClasses: ['odd-row', 'even-row'],
|
|
columnDefs: [
|
|
{ targets: [7], visible: false, searchable: false }, // depth
|
|
{ targets: [0], orderable: false, searchable: false }, // actions
|
|
{ targets: [3], visible: false }, // parent (used for grouping)
|
|
],
|
|
rowGroup: {
|
|
dataSrc: 3,
|
|
startRender: (rows, group) =>
|
|
`${group} <span class="text-muted">(${rows.count()})</span>`,
|
|
},
|
|
});
|
|
};
|
|
|
|
const renderParentOptions = (options) => {
|
|
if (!parentSelect) return;
|
|
const selectedValue = parentSelect.value;
|
|
parentSelect.innerHTML = '<option value="">— Top level —</option>';
|
|
options
|
|
.filter((opt) => opt && typeof opt === 'object')
|
|
.forEach((opt) => {
|
|
const option = document.createElement('option');
|
|
option.value = String(opt.id ?? '');
|
|
option.textContent = opt.label ?? '';
|
|
parentSelect.appendChild(option);
|
|
});
|
|
const existing = Array.from(parentSelect.options).find((opt) => opt.value === selectedValue);
|
|
if (existing) {
|
|
parentSelect.value = selectedValue;
|
|
}
|
|
};
|
|
|
|
const renderRolesOptions = (roles) => {
|
|
if (!rolesSelect) return;
|
|
|
|
const previouslySelected = Array.from(rolesSelect.selectedOptions).map((opt) => opt.value);
|
|
rolesSelect.innerHTML = '';
|
|
|
|
roles
|
|
.filter((role) => role && typeof role === 'object')
|
|
.forEach((role) => {
|
|
const option = document.createElement('option');
|
|
option.value = String(role.id ?? '');
|
|
option.textContent = role.name ?? '';
|
|
rolesSelect.appendChild(option);
|
|
});
|
|
|
|
Array.from(rolesSelect.options).forEach((option) => {
|
|
option.selected = previouslySelected.includes(option.value);
|
|
});
|
|
};
|
|
|
|
const buildBadge = (text) => {
|
|
const span = document.createElement('span');
|
|
span.className = 'badge badge-role me-1 mb-1';
|
|
span.textContent = text;
|
|
return span;
|
|
};
|
|
|
|
const renderTable = () => {
|
|
if (!tableBody) return;
|
|
|
|
destroyDataTable();
|
|
itemsById = new Map();
|
|
tableBody.innerHTML = '';
|
|
|
|
if (!Array.isArray(items) || items.length === 0) {
|
|
const row = document.createElement('tr');
|
|
const cell = document.createElement('td');
|
|
cell.colSpan = 8;
|
|
cell.classList.add('text-center', 'text-muted');
|
|
cell.textContent = 'No menu items found.';
|
|
row.appendChild(cell);
|
|
tableBody.appendChild(row);
|
|
return;
|
|
}
|
|
|
|
items.forEach((item) => {
|
|
itemsById.set(item.id, item);
|
|
|
|
const tr = document.createElement('tr');
|
|
tr.dataset.itemId = item.id ?? '';
|
|
|
|
const actionsTd = document.createElement('td');
|
|
actionsTd.classList.add('text-nowrap');
|
|
const editBtn = document.createElement('button');
|
|
editBtn.type = 'button';
|
|
editBtn.className = 'btn btn-sm btn-outline-primary me-1';
|
|
editBtn.textContent = 'Edit';
|
|
editBtn.addEventListener('click', () => editItemById(item.id));
|
|
actionsTd.appendChild(editBtn);
|
|
|
|
const deleteLink = document.createElement('a');
|
|
const sanitizedBase = (deleteBase || '').replace(/\/+$/, '');
|
|
deleteLink.href = `${sanitizedBase}/${item.id}`;
|
|
deleteLink.className = 'btn btn-sm btn-outline-danger';
|
|
deleteLink.textContent = 'Delete';
|
|
deleteLink.addEventListener('click', (event) => {
|
|
if (!confirm('Delete?')) {
|
|
event.preventDefault();
|
|
}
|
|
});
|
|
actionsTd.appendChild(deleteLink);
|
|
tr.appendChild(actionsTd);
|
|
|
|
const labelTd = document.createElement('td');
|
|
labelTd.dataset.order = (item.label ?? '').toLowerCase();
|
|
const indentSpan = document.createElement('span');
|
|
indentSpan.classList.add('text-muted');
|
|
indentSpan.textContent = '— '.repeat(item.depth ?? 0);
|
|
labelTd.appendChild(indentSpan);
|
|
labelTd.appendChild(document.createTextNode(item.label ?? ''));
|
|
tr.appendChild(labelTd);
|
|
|
|
const urlTd = document.createElement('td');
|
|
if (item.url) {
|
|
urlTd.textContent = item.url;
|
|
} else {
|
|
urlTd.innerHTML = '<span class="text-muted">—</span>';
|
|
}
|
|
tr.appendChild(urlTd);
|
|
|
|
const parentTd = document.createElement('td');
|
|
parentTd.textContent = item.parent_label ?? '—';
|
|
tr.appendChild(parentTd);
|
|
|
|
const rolesTd = document.createElement('td');
|
|
if (Array.isArray(item.roles?.names) && item.roles.names.length > 0) {
|
|
item.roles.names.forEach((roleName) => {
|
|
if (!roleName) return;
|
|
rolesTd.appendChild(buildBadge(roleName));
|
|
});
|
|
} else {
|
|
rolesTd.innerHTML = '<span class="text-muted">—</span>';
|
|
}
|
|
tr.appendChild(rolesTd);
|
|
|
|
const orderTd = document.createElement('td');
|
|
orderTd.textContent = Number.isFinite(item.order) ? item.order : 0;
|
|
tr.appendChild(orderTd);
|
|
|
|
const enabledTd = document.createElement('td');
|
|
if (item.enabled) {
|
|
enabledTd.innerHTML = '<span class="badge bg-success">Yes</span>';
|
|
} else {
|
|
enabledTd.innerHTML = '<span class="badge bg-light text-dark">No</span>';
|
|
}
|
|
tr.appendChild(enabledTd);
|
|
|
|
const depthTd = document.createElement('td');
|
|
depthTd.textContent = item.depth ?? 0;
|
|
tr.appendChild(depthTd);
|
|
|
|
tableBody.appendChild(tr);
|
|
});
|
|
|
|
initDataTable();
|
|
};
|
|
|
|
const resetForm = () => {
|
|
if (!form) return;
|
|
form.reset();
|
|
const idField = document.getElementById('item_id');
|
|
if (idField) idField.value = '';
|
|
if (parentSelect) parentSelect.value = '';
|
|
if (rolesSelect) {
|
|
Array.from(rolesSelect.options).forEach((option) => {
|
|
option.selected = false;
|
|
});
|
|
}
|
|
};
|
|
|
|
const editItemById = (id) => {
|
|
const item = itemsById.get(id);
|
|
if (!item) return;
|
|
|
|
resetForm();
|
|
|
|
document.getElementById('item_id').value = item.id ?? '';
|
|
document.getElementById('menu_parent_id').value =
|
|
item.parent_id !== null && item.parent_id !== undefined
|
|
? String(item.parent_id)
|
|
: '';
|
|
document.getElementById('label').value = item.label ?? '';
|
|
document.getElementById('url').value = item.url ?? '';
|
|
document.getElementById('target').value = item.target ?? '';
|
|
document.getElementById('sort_order').value =
|
|
Number.isFinite(item.order) ? item.order : 0;
|
|
document.getElementById('is_enabled').checked = !!item.enabled;
|
|
|
|
if (rolesSelect && Array.isArray(item.roles?.ids)) {
|
|
const ids = item.roles.ids.map((value) => String(value));
|
|
Array.from(rolesSelect.options).forEach((option) => {
|
|
option.selected = ids.includes(option.value);
|
|
});
|
|
}
|
|
|
|
window.scrollTo({
|
|
top: 0,
|
|
behavior: 'smooth',
|
|
});
|
|
};
|
|
|
|
const fetchNavData = () => {
|
|
if (!navEndpoint) return;
|
|
|
|
tableBody.innerHTML = `
|
|
<tr>
|
|
<td colspan="8" class="text-center text-muted">Loading menu…</td>
|
|
</tr>
|
|
`;
|
|
|
|
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) => {
|
|
items = Array.isArray(payload?.items) ? payload.items : [];
|
|
renderParentOptions(payload?.parentOptions ?? []);
|
|
renderRolesOptions(payload?.roles ?? []);
|
|
renderTable();
|
|
resetForm();
|
|
})
|
|
.catch((error) => {
|
|
console.error(error);
|
|
tableBody.innerHTML = `
|
|
<tr>
|
|
<td colspan="8" class="text-center text-danger">
|
|
Failed to load menu items.
|
|
</td>
|
|
</tr>
|
|
`;
|
|
});
|
|
};
|
|
|
|
if (resetButton) {
|
|
resetButton.addEventListener('click', resetForm);
|
|
}
|
|
|
|
fetchNavData();
|
|
});
|
|
</script>
|
|
<?= $this->endSection() ?>
|