| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428 |
- <?php
- require_once __DIR__ . '/auth.php';
- require_once __DIR__ . '/layout.php';
- require_admin();
- $pdo = get_pdo();
- if ($_SERVER['REQUEST_METHOD'] === 'POST') {
- require_csrf();
- $action = $_POST['action'] ?? '';
- if ($action === 'add') {
- $name = trim($_POST['itemName'] ?? '');
- $gid = (int)$_POST['groupID'];
- $order = (int)($_POST['sortOrder'] ?? 0);
- if ($name !== '' && $gid > 0) {
- $pdo->prepare("INSERT INTO items (groupID,itemName,sortOrder) VALUES (?,?,?)")->execute([$gid,$name,$order]);
- flash('success', "Item '$name' added.");
- } else { flash('error', 'Please fill all fields.'); }
- }
- if ($action === 'edit') {
- $id = (int)$_POST['itemID'];
- $name = trim($_POST['itemName'] ?? '');
- $gid = (int)$_POST['groupID'];
- $order = (int)($_POST['sortOrder'] ?? 0);
- if ($name !== '') {
- $pdo->prepare("UPDATE items SET itemName=?,groupID=?,sortOrder=? WHERE itemID=?")->execute([$name,$gid,$order,$id]);
- flash('success', 'Item updated.');
- } else { flash('error', 'Name required.'); }
- }
- if ($action === 'delete') {
- $id = (int)$_POST['itemID'];
- $pdo->prepare("DELETE FROM items WHERE itemID=?")->execute([$id]);
- flash('success', 'Item deleted.');
- }
- header('Location: items.php'); exit;
- }
- $igroups = $pdo->query("SELECT * FROM item_groups ORDER BY sortOrder,groupID")->fetchAll();
- $items = $pdo->query("
- 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
- ")->fetchAll();
- admin_head('Items', 'items.php');
- show_alerts();
- ?>
- <h1>Items</h1>
- <style>
- /* Advisory duplicate hint under an item-name field. Never blocks a save. */
- .dup-hint{margin:-.55rem 0 .9rem;border:1.5px solid var(--border);border-radius:var(--radius);
- background:var(--bg);padding:.4rem 0;font-size:.85rem;}
- .dup-hint-head{padding:.15rem .8rem .3rem;font-size:.72rem;font-weight:600;letter-spacing:.06em;
- text-transform:uppercase;color:var(--muted);}
- .dup-hint.exact{border-color:rgba(232,197,71,.45);}
- .dup-hint.exact .dup-hint-head{color:var(--accent);}
- .dup-row{display:flex;gap:.5rem;align-items:center;padding:.25rem .8rem;}
- .dup-name{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
- .dup-id{color:var(--muted);font-size:.78rem;}
- /* In the table the hint floats, so a row does not jump in height while editing.
- The cell's own overflow:hidden would clip it, hence the override. */
- .tbl-fixed tr[data-editing="true"] td{overflow:visible;}
- .dup-anchor{position:relative;}
- .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">
- <h2>Add New Item</h2>
- <form method="POST">
- <?= csrf_field() ?>
- <input type="hidden" name="action" value="add">
- <div class="form-row">
- <div>
- <label>Item Name</label>
- <input type="text" name="itemName" id="new-item-name" autocomplete="off" placeholder="e.g. Rain jacket" required>
- <div class="dup-hint" id="dup-hint" hidden></div>
- </div>
- <div>
- <label>Group</label>
- <select name="groupID" required>
- <option value="">– select –</option>
- <?php foreach ($igroups as $g): ?>
- <option value="<?= $g['groupID'] ?>"><?= htmlspecialchars($g['groupName']) ?></option>
- <?php endforeach; ?>
- </select>
- </div>
- </div>
- <div>
- <label>Sort Order</label>
- <input type="number" name="sortOrder" value="0" min="0" style="max-width:120px;">
- </div>
- <div style="display:flex;gap:.5rem;margin-top:.25rem;">
- <button type="submit" class="btn btn-primary">➕ Add</button>
- </div>
- </form>
- </div>
- <?php foreach ($items as $item): ?>
- <form id="edit-form-<?= $item['itemID'] ?>" method="POST">
- <?= csrf_field() ?>
- <input type="hidden" name="action" value="edit">
- <input type="hidden" name="itemID" value="<?= $item['itemID'] ?>">
- </form>
- <?php endforeach; ?>
- <table class="tbl tbl-fixed" id="items-table">
- <colgroup>
- <col style="width:60px">
- <col>
- <col style="width:180px">
- <col style="width:90px">
- <col style="width:120px">
- <col style="width:200px">
- </colgroup>
- <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'];
- $maps = (int)$item['mappingCount'];
- ?>
- <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>
- <div class="edit">
- <div class="dup-anchor">
- <input type="text" name="itemName" form="edit-form-<?= $id ?>" autocomplete="off"
- value="<?= htmlspecialchars($item['itemName']) ?>"
- data-dup-hint="dup-hint-<?= $id ?>" data-item-id="<?= $id ?>" required>
- <div class="dup-hint dup-hint-float" id="dup-hint-<?= $id ?>" hidden></div>
- </div>
- </div>
- </td>
- <td>
- <div class="view"><span class="badge badge-group"><?= htmlspecialchars($item['groupName']) ?></span></div>
- <div class="edit">
- <select name="groupID" form="edit-form-<?= $id ?>" required>
- <?php foreach ($igroups as $g): ?>
- <option value="<?= $g['groupID'] ?>" <?= $item['groupID'] == $g['groupID'] ? 'selected' : '' ?>><?= htmlspecialchars($g['groupName']) ?></option>
- <?php endforeach; ?>
- </select>
- </div>
- </td>
- <td>
- <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>
- <form method="POST" onsubmit="return confirm('Delete this item?')">
- <?= csrf_field() ?>
- <input type="hidden" name="action" value="delete">
- <input type="hidden" name="itemID" value="<?= $id ?>">
- <button class="btn btn-sm btn-danger">Delete</button>
- </form>
- </div>
- <div class="edit actions-buttons">
- <button type="submit" form="edit-form-<?= $id ?>" class="btn btn-sm btn-primary">💾 Save</button>
- <button type="button" onclick="cancelEdit(<?= $id ?>)" class="btn btn-sm btn-secondary">Cancel</button>
- </div>
- </td>
- </tr>
- <?php endforeach; ?>
- </tbody>
- </table>
- <script>
- function startEdit(id) {
- var row = document.getElementById('row-' + id);
- row.dataset.editing = 'true';
- var input = row.querySelector('.edit input[name="itemName"]');
- if (input) input.focus();
- }
- function cancelEdit(id) {
- document.getElementById('row-' + id).dataset.editing = 'false';
- }
- </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
- // on every inline rename.
- (function () {
- var ITEMS = <?= json_encode(array_map(function ($i) {
- return ['id' => (int)$i['itemID'], 'name' => $i['itemName'], 'group' => $i['groupName']];
- }, $items), JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>;
- var MAX_HITS = 8;
- // Lower-cased, stripped of diacritics and punctuation, so "Rain-Jacket" and
- // "rain jacket" collapse onto the same key.
- function norm(s) {
- return s.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '')
- .replace(/[^a-z0-9]+/g, ' ').replace(/\s+/g, ' ').trim();
- }
- // Crude singular, enough to make "socks" and "sock" match.
- function singular(t) {
- return t.length > 3 && t.slice(-1) === 's' ? t.slice(0, -1) : t;
- }
- function tokens(s) {
- return norm(s).split(' ').filter(Boolean).map(singular);
- }
- function key(s) {
- return tokens(s).join(' ');
- }
- ITEMS.forEach(function (it) {
- it.key = key(it.name);
- it.tokens = it.key.split(' ').filter(Boolean);
- });
- // Levenshtein distance, abandoned as soon as it exceeds `max`.
- function lev(a, b, max) {
- var prev = [], cur = [], i, j;
- for (j = 0; j <= b.length; j++) prev[j] = j;
- for (i = 1; i <= a.length; i++) {
- cur = [i];
- var best = i;
- for (j = 1; j <= b.length; j++) {
- cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1,
- prev[j - 1] + (a.charAt(i - 1) === b.charAt(j - 1) ? 0 : 1));
- if (cur[j] < best) best = cur[j];
- }
- if (best > max) return max + 1;
- prev = cur;
- }
- return prev[b.length];
- }
- // Higher is more similar; 0 means "not worth showing".
- function score(qTokens, qKey, it) {
- if (!qKey) return 0;
- if (it.key === qKey) return 1000; // same item, different spelling
- if (it.key.indexOf(qKey) === 0) return 500; // typed name is a prefix
- if (it.key.indexOf(qKey) !== -1) return 400; // ...or appears inside
- var hits = qTokens.filter(function (q) {
- return it.tokens.some(function (t) {
- // German compounds mean one word often sits inside another ("Socken"
- // in "Skisocken"), so substrings count - but only between tokens of
- // real length, else the "T" of "T-Shirt" matches half the table.
- return t === q || (q.length >= 3 && t.length >= 3 &&
- (t.indexOf(q) !== -1 || q.indexOf(t) !== -1));
- });
- }).length;
- // All words matched, just in another order ("jacket rain" -> "Rain jacket").
- if (hits) return hits === qTokens.length ? 300 + hits : 100 * hits;
- // Typos are the usual way a duplicate sneaks in ("sonnenbrile").
- var slack = qKey.length >= 8 ? 2 : 1;
- if (Math.abs(it.key.length - qKey.length) <= slack && lev(it.key, qKey, slack) <= slack) {
- return 200;
- }
- return 0;
- }
- function esc(s) {
- return s.replace(/[&<>"]/g, function (c) {
- return {'&': '&', '<': '<', '>': '>', '"': '"'}[c];
- });
- }
- // Keeps `hint` in sync with what is typed into `input`. A rename passes its
- // own itemID as `selfId` so the row never flags itself, and stays quiet until
- // the name actually differs from the one it started with.
- function attach(input, hint, selfId) {
- if (!input || !hint) return;
- var baseline = key(input.defaultValue || '');
- var baseClass = hint.className;
- function render() {
- var qKey = key(input.value.trim());
- if (qKey.length < 2 || (baseline && qKey === baseline)) { hint.hidden = true; return; }
- var qTokens = qKey.split(' ');
- var hits = ITEMS.filter(function (it) { return it.id !== selfId; })
- .map(function (it) { return {it: it, s: score(qTokens, qKey, it)}; })
- .filter(function (h) { return h.s > 0; })
- .sort(function (a, b) {
- return b.s - a.s || a.it.name.localeCompare(b.it.name);
- });
- if (!hits.length) { hint.hidden = true; return; }
- var exact = hits[0].s === 1000;
- var shown = hits.slice(0, MAX_HITS);
- var head = exact
- ? '⚠ “' + esc(hits[0].it.name) + '” already exists'
- : shown.length + ' similar item' + (shown.length === 1 ? '' : 's') + ' already in the database';
- var html = '<div class="dup-hint-head">' + head + '</div>';
- shown.forEach(function (h) {
- html += '<div class="dup-row">'
- + '<span class="dup-id">#' + h.it.id + '</span>'
- + '<span class="dup-name">' + esc(h.it.name) + '</span>'
- + '<span class="badge badge-group">' + esc(h.it.group) + '</span>'
- + '</div>';
- });
- if (hits.length > shown.length) {
- html += '<div class="dup-row dup-id">+ ' + (hits.length - shown.length) + ' more</div>';
- }
- hint.className = baseClass + (exact ? ' exact' : '');
- hint.innerHTML = html;
- hint.hidden = false;
- }
- input.addEventListener('input', render);
- input.addEventListener('focus', render);
- render();
- }
- attach(document.getElementById('new-item-name'), document.getElementById('dup-hint'), 0);
- Array.prototype.forEach.call(
- document.querySelectorAll('input[data-dup-hint]'),
- function (input) {
- attach(input, document.getElementById(input.dataset.dupHint), +input.dataset.itemId);
- });
- })();
- </script>
- <?php admin_foot(); ?>
|