|
|
@@ -37,7 +37,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
|
|
|
|
|
$igroups = $pdo->query("SELECT * FROM item_groups ORDER BY sortOrder,groupID")->fetchAll();
|
|
|
$items = $pdo->query("
|
|
|
- SELECT i.*, ig.groupName
|
|
|
+ SELECT i.*, ig.groupName,
|
|
|
+ (SELECT COUNT(*) FROM activity_item_map m WHERE m.itemID = i.itemID) AS mappingCount
|
|
|
FROM items i
|
|
|
JOIN item_groups ig ON ig.groupID = i.groupID
|
|
|
ORDER BY ig.sortOrder, i.sortOrder, i.itemID
|
|
|
@@ -66,6 +67,18 @@ show_alerts();
|
|
|
.dup-hint-float{position:absolute;z-index:30;top:calc(100% + 4px);left:0;margin:0;
|
|
|
min-width:100%;width:max-content;max-width:min(420px,60vw);
|
|
|
background:var(--surface);box-shadow:0 8px 24px rgba(0,0,0,.45);}
|
|
|
+
|
|
|
+/* Clickable column headers. The arrow is driven by aria-sort, so the visual
|
|
|
+ state and the one screen readers announce can never drift apart. */
|
|
|
+.th-sort{cursor:pointer;user-select:none;}
|
|
|
+.th-sort:hover{color:var(--text);}
|
|
|
+.th-sort:focus-visible{outline:2px solid var(--accent2);outline-offset:-2px;}
|
|
|
+.th-sort::after{content:'\2195';margin-left:.35rem;opacity:.3;}
|
|
|
+.th-sort[aria-sort="ascending"]{color:var(--accent);}
|
|
|
+.th-sort[aria-sort="ascending"]::after{content:'\2191';opacity:1;}
|
|
|
+.th-sort[aria-sort="descending"]{color:var(--accent);}
|
|
|
+.th-sort[aria-sort="descending"]::after{content:'\2193';opacity:1;}
|
|
|
+.count-zero{color:var(--muted);}
|
|
|
</style>
|
|
|
|
|
|
<div class="card">
|
|
|
@@ -107,20 +120,32 @@ show_alerts();
|
|
|
</form>
|
|
|
<?php endforeach; ?>
|
|
|
|
|
|
-<table class="tbl tbl-fixed">
|
|
|
+<table class="tbl tbl-fixed" id="items-table">
|
|
|
<colgroup>
|
|
|
<col style="width:60px">
|
|
|
<col>
|
|
|
<col style="width:180px">
|
|
|
- <col style="width:100px">
|
|
|
+ <col style="width:90px">
|
|
|
+ <col style="width:120px">
|
|
|
<col style="width:200px">
|
|
|
</colgroup>
|
|
|
- <thead><tr><th>#</th><th>Name</th><th>Group</th><th>Sort</th><th></th></tr></thead>
|
|
|
+ <thead><tr>
|
|
|
+ <th class="th-sort" data-sort-key="id" data-sort-type="num" tabindex="0" aria-sort="none">#</th>
|
|
|
+ <th class="th-sort" data-sort-key="name" data-sort-type="text" tabindex="0" aria-sort="none">Name</th>
|
|
|
+ <th class="th-sort" data-sort-key="group" data-sort-type="text" tabindex="0" aria-sort="none">Group</th>
|
|
|
+ <th class="th-sort" data-sort-key="order" data-sort-type="num" tabindex="0" aria-sort="none">Sort</th>
|
|
|
+ <th class="th-sort" data-sort-key="maps" data-sort-type="num" tabindex="0" aria-sort="none">Mappings</th>
|
|
|
+ <th></th>
|
|
|
+ </tr></thead>
|
|
|
<tbody>
|
|
|
<?php foreach ($items as $item):
|
|
|
- $id = $item['itemID'];
|
|
|
+ $id = $item['itemID'];
|
|
|
+ $maps = (int)$item['mappingCount'];
|
|
|
?>
|
|
|
- <tr id="row-<?= $id ?>" data-editing="false">
|
|
|
+ <tr id="row-<?= $id ?>" data-editing="false"
|
|
|
+ data-id="<?= $id ?>" data-name="<?= htmlspecialchars($item['itemName']) ?>"
|
|
|
+ data-group="<?= htmlspecialchars($item['groupName']) ?>"
|
|
|
+ data-order="<?= (int)$item['sortOrder'] ?>" data-maps="<?= $maps ?>">
|
|
|
<td style="color:var(--muted)"><?= $id ?></td>
|
|
|
<td>
|
|
|
<div class="view"><?= htmlspecialchars($item['itemName']) ?></div>
|
|
|
@@ -147,6 +172,13 @@ show_alerts();
|
|
|
<div class="view"><?= $item['sortOrder'] ?></div>
|
|
|
<div class="edit"><input type="number" name="sortOrder" form="edit-form-<?= $id ?>" value="<?= (int)$item['sortOrder'] ?>" min="0"></div>
|
|
|
</td>
|
|
|
+ <td>
|
|
|
+ <?php if ($maps > 0): ?>
|
|
|
+ <span class="badge badge-group" title="Used in <?= $maps ?> activity mapping<?= $maps === 1 ? '' : 's' ?>"><?= $maps ?></span>
|
|
|
+ <?php else: ?>
|
|
|
+ <span class="count-zero" title="Not used in any activity">0</span>
|
|
|
+ <?php endif; ?>
|
|
|
+ </td>
|
|
|
<td>
|
|
|
<div class="view actions-buttons">
|
|
|
<button type="button" onclick="startEdit(<?= $id ?>)" class="btn btn-sm btn-teal">Edit</button>
|
|
|
@@ -179,6 +211,78 @@ function cancelEdit(id) {
|
|
|
}
|
|
|
</script>
|
|
|
|
|
|
+<script>
|
|
|
+// Column sorting. Each row carries its sort keys as data attributes, so the
|
|
|
+// comparison never has to dig through the view/edit markup in the cells.
|
|
|
+(function () {
|
|
|
+ var table = document.getElementById('items-table');
|
|
|
+ if (!table || !table.tBodies.length) return;
|
|
|
+
|
|
|
+ var tbody = table.tBodies[0];
|
|
|
+ var rows = Array.prototype.slice.call(tbody.rows);
|
|
|
+ // The order the server sent - group, then sort order, then ID - is not
|
|
|
+ // reproducible from any single column, so it is kept as the third state of
|
|
|
+ // the cycle rather than being lost after the first click.
|
|
|
+ rows.forEach(function (row, i) { row.dataset.defaultPos = i; });
|
|
|
+
|
|
|
+ var headers = Array.prototype.slice.call(table.querySelectorAll('th.th-sort'));
|
|
|
+ var STORE = 'packit.items.sort';
|
|
|
+ var collator = new Intl.Collator(undefined, {sensitivity: 'base', numeric: true});
|
|
|
+
|
|
|
+ function compare(th, a, b) {
|
|
|
+ var k = th.dataset.sortKey;
|
|
|
+ return th.dataset.sortType === 'num'
|
|
|
+ ? (+a.dataset[k]) - (+b.dataset[k])
|
|
|
+ : collator.compare(a.dataset[k], b.dataset[k]);
|
|
|
+ }
|
|
|
+
|
|
|
+ // dir: 1 ascending, -1 descending, 0 back to the server's order.
|
|
|
+ function apply(th, dir) {
|
|
|
+ headers.forEach(function (h) { h.setAttribute('aria-sort', 'none'); });
|
|
|
+ if (th && dir) {
|
|
|
+ th.setAttribute('aria-sort', dir > 0 ? 'ascending' : 'descending');
|
|
|
+ }
|
|
|
+
|
|
|
+ rows.slice().sort(function (a, b) {
|
|
|
+ // Equal keys keep the server's order, so sorting by Group still lists
|
|
|
+ // each group's items in their configured sequence.
|
|
|
+ return (dir && compare(th, a, b) * dir) || (a.dataset.defaultPos - b.dataset.defaultPos);
|
|
|
+ }).forEach(function (row) { tbody.appendChild(row); });
|
|
|
+
|
|
|
+ try {
|
|
|
+ if (th && dir) {
|
|
|
+ sessionStorage.setItem(STORE, th.dataset.sortKey + ':' + dir);
|
|
|
+ } else {
|
|
|
+ sessionStorage.removeItem(STORE);
|
|
|
+ }
|
|
|
+ } catch (e) { /* private mode - sorting still works, it just will not stick */ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function toggle(th) {
|
|
|
+ var state = th.getAttribute('aria-sort');
|
|
|
+ apply(th, state === 'ascending' ? -1 : state === 'descending' ? 0 : 1);
|
|
|
+ }
|
|
|
+
|
|
|
+ headers.forEach(function (th) {
|
|
|
+ th.addEventListener('click', function () { toggle(th); });
|
|
|
+ th.addEventListener('keydown', function (e) {
|
|
|
+ if (e.key === 'Enter' || e.key === ' ' || e.key === 'Spacebar') {
|
|
|
+ e.preventDefault();
|
|
|
+ toggle(th);
|
|
|
+ }
|
|
|
+ });
|
|
|
+ });
|
|
|
+
|
|
|
+ // Saving an item redirects back to this page, so the chosen order is
|
|
|
+ // restored instead of snapping back to the default on every edit.
|
|
|
+ try {
|
|
|
+ var saved = (sessionStorage.getItem(STORE) || '').split(':');
|
|
|
+ var th = headers.filter(function (h) { return h.dataset.sortKey === saved[0]; })[0];
|
|
|
+ if (th) apply(th, saved[1] === '-1' ? -1 : 1);
|
|
|
+ } catch (e) { /* nothing stored, or storage blocked */ }
|
|
|
+})();
|
|
|
+</script>
|
|
|
+
|
|
|
<script>
|
|
|
// Duplicate hints: purely advisory. They never block a save - they only show
|
|
|
// which existing items resemble the name being typed, both on the add form and
|