items.php 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428
  1. <?php
  2. require_once __DIR__ . '/auth.php';
  3. require_once __DIR__ . '/layout.php';
  4. require_admin();
  5. $pdo = get_pdo();
  6. if ($_SERVER['REQUEST_METHOD'] === 'POST') {
  7. require_csrf();
  8. $action = $_POST['action'] ?? '';
  9. if ($action === 'add') {
  10. $name = trim($_POST['itemName'] ?? '');
  11. $gid = (int)$_POST['groupID'];
  12. $order = (int)($_POST['sortOrder'] ?? 0);
  13. if ($name !== '' && $gid > 0) {
  14. $pdo->prepare("INSERT INTO items (groupID,itemName,sortOrder) VALUES (?,?,?)")->execute([$gid,$name,$order]);
  15. flash('success', "Item '$name' added.");
  16. } else { flash('error', 'Please fill all fields.'); }
  17. }
  18. if ($action === 'edit') {
  19. $id = (int)$_POST['itemID'];
  20. $name = trim($_POST['itemName'] ?? '');
  21. $gid = (int)$_POST['groupID'];
  22. $order = (int)($_POST['sortOrder'] ?? 0);
  23. if ($name !== '') {
  24. $pdo->prepare("UPDATE items SET itemName=?,groupID=?,sortOrder=? WHERE itemID=?")->execute([$name,$gid,$order,$id]);
  25. flash('success', 'Item updated.');
  26. } else { flash('error', 'Name required.'); }
  27. }
  28. if ($action === 'delete') {
  29. $id = (int)$_POST['itemID'];
  30. $pdo->prepare("DELETE FROM items WHERE itemID=?")->execute([$id]);
  31. flash('success', 'Item deleted.');
  32. }
  33. header('Location: items.php'); exit;
  34. }
  35. $igroups = $pdo->query("SELECT * FROM item_groups ORDER BY sortOrder,groupID")->fetchAll();
  36. $items = $pdo->query("
  37. SELECT i.*, ig.groupName,
  38. (SELECT COUNT(*) FROM activity_item_map m WHERE m.itemID = i.itemID) AS mappingCount
  39. FROM items i
  40. JOIN item_groups ig ON ig.groupID = i.groupID
  41. ORDER BY ig.sortOrder, i.sortOrder, i.itemID
  42. ")->fetchAll();
  43. admin_head('Items', 'items.php');
  44. show_alerts();
  45. ?>
  46. <h1>Items</h1>
  47. <style>
  48. /* Advisory duplicate hint under an item-name field. Never blocks a save. */
  49. .dup-hint{margin:-.55rem 0 .9rem;border:1.5px solid var(--border);border-radius:var(--radius);
  50. background:var(--bg);padding:.4rem 0;font-size:.85rem;}
  51. .dup-hint-head{padding:.15rem .8rem .3rem;font-size:.72rem;font-weight:600;letter-spacing:.06em;
  52. text-transform:uppercase;color:var(--muted);}
  53. .dup-hint.exact{border-color:rgba(232,197,71,.45);}
  54. .dup-hint.exact .dup-hint-head{color:var(--accent);}
  55. .dup-row{display:flex;gap:.5rem;align-items:center;padding:.25rem .8rem;}
  56. .dup-name{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
  57. .dup-id{color:var(--muted);font-size:.78rem;}
  58. /* In the table the hint floats, so a row does not jump in height while editing.
  59. The cell's own overflow:hidden would clip it, hence the override. */
  60. .tbl-fixed tr[data-editing="true"] td{overflow:visible;}
  61. .dup-anchor{position:relative;}
  62. .dup-hint-float{position:absolute;z-index:30;top:calc(100% + 4px);left:0;margin:0;
  63. min-width:100%;width:max-content;max-width:min(420px,60vw);
  64. background:var(--surface);box-shadow:0 8px 24px rgba(0,0,0,.45);}
  65. /* Clickable column headers. The arrow is driven by aria-sort, so the visual
  66. state and the one screen readers announce can never drift apart. */
  67. .th-sort{cursor:pointer;user-select:none;}
  68. .th-sort:hover{color:var(--text);}
  69. .th-sort:focus-visible{outline:2px solid var(--accent2);outline-offset:-2px;}
  70. .th-sort::after{content:'\2195';margin-left:.35rem;opacity:.3;}
  71. .th-sort[aria-sort="ascending"]{color:var(--accent);}
  72. .th-sort[aria-sort="ascending"]::after{content:'\2191';opacity:1;}
  73. .th-sort[aria-sort="descending"]{color:var(--accent);}
  74. .th-sort[aria-sort="descending"]::after{content:'\2193';opacity:1;}
  75. .count-zero{color:var(--muted);}
  76. </style>
  77. <div class="card">
  78. <h2>Add New Item</h2>
  79. <form method="POST">
  80. <?= csrf_field() ?>
  81. <input type="hidden" name="action" value="add">
  82. <div class="form-row">
  83. <div>
  84. <label>Item Name</label>
  85. <input type="text" name="itemName" id="new-item-name" autocomplete="off" placeholder="e.g. Rain jacket" required>
  86. <div class="dup-hint" id="dup-hint" hidden></div>
  87. </div>
  88. <div>
  89. <label>Group</label>
  90. <select name="groupID" required>
  91. <option value="">– select –</option>
  92. <?php foreach ($igroups as $g): ?>
  93. <option value="<?= $g['groupID'] ?>"><?= htmlspecialchars($g['groupName']) ?></option>
  94. <?php endforeach; ?>
  95. </select>
  96. </div>
  97. </div>
  98. <div>
  99. <label>Sort Order</label>
  100. <input type="number" name="sortOrder" value="0" min="0" style="max-width:120px;">
  101. </div>
  102. <div style="display:flex;gap:.5rem;margin-top:.25rem;">
  103. <button type="submit" class="btn btn-primary">➕ Add</button>
  104. </div>
  105. </form>
  106. </div>
  107. <?php foreach ($items as $item): ?>
  108. <form id="edit-form-<?= $item['itemID'] ?>" method="POST">
  109. <?= csrf_field() ?>
  110. <input type="hidden" name="action" value="edit">
  111. <input type="hidden" name="itemID" value="<?= $item['itemID'] ?>">
  112. </form>
  113. <?php endforeach; ?>
  114. <table class="tbl tbl-fixed" id="items-table">
  115. <colgroup>
  116. <col style="width:60px">
  117. <col>
  118. <col style="width:180px">
  119. <col style="width:90px">
  120. <col style="width:120px">
  121. <col style="width:200px">
  122. </colgroup>
  123. <thead><tr>
  124. <th class="th-sort" data-sort-key="id" data-sort-type="num" tabindex="0" aria-sort="none">#</th>
  125. <th class="th-sort" data-sort-key="name" data-sort-type="text" tabindex="0" aria-sort="none">Name</th>
  126. <th class="th-sort" data-sort-key="group" data-sort-type="text" tabindex="0" aria-sort="none">Group</th>
  127. <th class="th-sort" data-sort-key="order" data-sort-type="num" tabindex="0" aria-sort="none">Sort</th>
  128. <th class="th-sort" data-sort-key="maps" data-sort-type="num" tabindex="0" aria-sort="none">Mappings</th>
  129. <th></th>
  130. </tr></thead>
  131. <tbody>
  132. <?php foreach ($items as $item):
  133. $id = $item['itemID'];
  134. $maps = (int)$item['mappingCount'];
  135. ?>
  136. <tr id="row-<?= $id ?>" data-editing="false"
  137. data-id="<?= $id ?>" data-name="<?= htmlspecialchars($item['itemName']) ?>"
  138. data-group="<?= htmlspecialchars($item['groupName']) ?>"
  139. data-order="<?= (int)$item['sortOrder'] ?>" data-maps="<?= $maps ?>">
  140. <td style="color:var(--muted)"><?= $id ?></td>
  141. <td>
  142. <div class="view"><?= htmlspecialchars($item['itemName']) ?></div>
  143. <div class="edit">
  144. <div class="dup-anchor">
  145. <input type="text" name="itemName" form="edit-form-<?= $id ?>" autocomplete="off"
  146. value="<?= htmlspecialchars($item['itemName']) ?>"
  147. data-dup-hint="dup-hint-<?= $id ?>" data-item-id="<?= $id ?>" required>
  148. <div class="dup-hint dup-hint-float" id="dup-hint-<?= $id ?>" hidden></div>
  149. </div>
  150. </div>
  151. </td>
  152. <td>
  153. <div class="view"><span class="badge badge-group"><?= htmlspecialchars($item['groupName']) ?></span></div>
  154. <div class="edit">
  155. <select name="groupID" form="edit-form-<?= $id ?>" required>
  156. <?php foreach ($igroups as $g): ?>
  157. <option value="<?= $g['groupID'] ?>" <?= $item['groupID'] == $g['groupID'] ? 'selected' : '' ?>><?= htmlspecialchars($g['groupName']) ?></option>
  158. <?php endforeach; ?>
  159. </select>
  160. </div>
  161. </td>
  162. <td>
  163. <div class="view"><?= $item['sortOrder'] ?></div>
  164. <div class="edit"><input type="number" name="sortOrder" form="edit-form-<?= $id ?>" value="<?= (int)$item['sortOrder'] ?>" min="0"></div>
  165. </td>
  166. <td>
  167. <?php if ($maps > 0): ?>
  168. <span class="badge badge-group" title="Used in <?= $maps ?> activity mapping<?= $maps === 1 ? '' : 's' ?>"><?= $maps ?></span>
  169. <?php else: ?>
  170. <span class="count-zero" title="Not used in any activity">0</span>
  171. <?php endif; ?>
  172. </td>
  173. <td>
  174. <div class="view actions-buttons">
  175. <button type="button" onclick="startEdit(<?= $id ?>)" class="btn btn-sm btn-teal">Edit</button>
  176. <form method="POST" onsubmit="return confirm('Delete this item?')">
  177. <?= csrf_field() ?>
  178. <input type="hidden" name="action" value="delete">
  179. <input type="hidden" name="itemID" value="<?= $id ?>">
  180. <button class="btn btn-sm btn-danger">Delete</button>
  181. </form>
  182. </div>
  183. <div class="edit actions-buttons">
  184. <button type="submit" form="edit-form-<?= $id ?>" class="btn btn-sm btn-primary">💾 Save</button>
  185. <button type="button" onclick="cancelEdit(<?= $id ?>)" class="btn btn-sm btn-secondary">Cancel</button>
  186. </div>
  187. </td>
  188. </tr>
  189. <?php endforeach; ?>
  190. </tbody>
  191. </table>
  192. <script>
  193. function startEdit(id) {
  194. var row = document.getElementById('row-' + id);
  195. row.dataset.editing = 'true';
  196. var input = row.querySelector('.edit input[name="itemName"]');
  197. if (input) input.focus();
  198. }
  199. function cancelEdit(id) {
  200. document.getElementById('row-' + id).dataset.editing = 'false';
  201. }
  202. </script>
  203. <script>
  204. // Column sorting. Each row carries its sort keys as data attributes, so the
  205. // comparison never has to dig through the view/edit markup in the cells.
  206. (function () {
  207. var table = document.getElementById('items-table');
  208. if (!table || !table.tBodies.length) return;
  209. var tbody = table.tBodies[0];
  210. var rows = Array.prototype.slice.call(tbody.rows);
  211. // The order the server sent - group, then sort order, then ID - is not
  212. // reproducible from any single column, so it is kept as the third state of
  213. // the cycle rather than being lost after the first click.
  214. rows.forEach(function (row, i) { row.dataset.defaultPos = i; });
  215. var headers = Array.prototype.slice.call(table.querySelectorAll('th.th-sort'));
  216. var STORE = 'packit.items.sort';
  217. var collator = new Intl.Collator(undefined, {sensitivity: 'base', numeric: true});
  218. function compare(th, a, b) {
  219. var k = th.dataset.sortKey;
  220. return th.dataset.sortType === 'num'
  221. ? (+a.dataset[k]) - (+b.dataset[k])
  222. : collator.compare(a.dataset[k], b.dataset[k]);
  223. }
  224. // dir: 1 ascending, -1 descending, 0 back to the server's order.
  225. function apply(th, dir) {
  226. headers.forEach(function (h) { h.setAttribute('aria-sort', 'none'); });
  227. if (th && dir) {
  228. th.setAttribute('aria-sort', dir > 0 ? 'ascending' : 'descending');
  229. }
  230. rows.slice().sort(function (a, b) {
  231. // Equal keys keep the server's order, so sorting by Group still lists
  232. // each group's items in their configured sequence.
  233. return (dir && compare(th, a, b) * dir) || (a.dataset.defaultPos - b.dataset.defaultPos);
  234. }).forEach(function (row) { tbody.appendChild(row); });
  235. try {
  236. if (th && dir) {
  237. sessionStorage.setItem(STORE, th.dataset.sortKey + ':' + dir);
  238. } else {
  239. sessionStorage.removeItem(STORE);
  240. }
  241. } catch (e) { /* private mode - sorting still works, it just will not stick */ }
  242. }
  243. function toggle(th) {
  244. var state = th.getAttribute('aria-sort');
  245. apply(th, state === 'ascending' ? -1 : state === 'descending' ? 0 : 1);
  246. }
  247. headers.forEach(function (th) {
  248. th.addEventListener('click', function () { toggle(th); });
  249. th.addEventListener('keydown', function (e) {
  250. if (e.key === 'Enter' || e.key === ' ' || e.key === 'Spacebar') {
  251. e.preventDefault();
  252. toggle(th);
  253. }
  254. });
  255. });
  256. // Saving an item redirects back to this page, so the chosen order is
  257. // restored instead of snapping back to the default on every edit.
  258. try {
  259. var saved = (sessionStorage.getItem(STORE) || '').split(':');
  260. var th = headers.filter(function (h) { return h.dataset.sortKey === saved[0]; })[0];
  261. if (th) apply(th, saved[1] === '-1' ? -1 : 1);
  262. } catch (e) { /* nothing stored, or storage blocked */ }
  263. })();
  264. </script>
  265. <script>
  266. // Duplicate hints: purely advisory. They never block a save - they only show
  267. // which existing items resemble the name being typed, both on the add form and
  268. // on every inline rename.
  269. (function () {
  270. var ITEMS = <?= json_encode(array_map(function ($i) {
  271. return ['id' => (int)$i['itemID'], 'name' => $i['itemName'], 'group' => $i['groupName']];
  272. }, $items), JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>;
  273. var MAX_HITS = 8;
  274. // Lower-cased, stripped of diacritics and punctuation, so "Rain-Jacket" and
  275. // "rain jacket" collapse onto the same key.
  276. function norm(s) {
  277. return s.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '')
  278. .replace(/[^a-z0-9]+/g, ' ').replace(/\s+/g, ' ').trim();
  279. }
  280. // Crude singular, enough to make "socks" and "sock" match.
  281. function singular(t) {
  282. return t.length > 3 && t.slice(-1) === 's' ? t.slice(0, -1) : t;
  283. }
  284. function tokens(s) {
  285. return norm(s).split(' ').filter(Boolean).map(singular);
  286. }
  287. function key(s) {
  288. return tokens(s).join(' ');
  289. }
  290. ITEMS.forEach(function (it) {
  291. it.key = key(it.name);
  292. it.tokens = it.key.split(' ').filter(Boolean);
  293. });
  294. // Levenshtein distance, abandoned as soon as it exceeds `max`.
  295. function lev(a, b, max) {
  296. var prev = [], cur = [], i, j;
  297. for (j = 0; j <= b.length; j++) prev[j] = j;
  298. for (i = 1; i <= a.length; i++) {
  299. cur = [i];
  300. var best = i;
  301. for (j = 1; j <= b.length; j++) {
  302. cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1,
  303. prev[j - 1] + (a.charAt(i - 1) === b.charAt(j - 1) ? 0 : 1));
  304. if (cur[j] < best) best = cur[j];
  305. }
  306. if (best > max) return max + 1;
  307. prev = cur;
  308. }
  309. return prev[b.length];
  310. }
  311. // Higher is more similar; 0 means "not worth showing".
  312. function score(qTokens, qKey, it) {
  313. if (!qKey) return 0;
  314. if (it.key === qKey) return 1000; // same item, different spelling
  315. if (it.key.indexOf(qKey) === 0) return 500; // typed name is a prefix
  316. if (it.key.indexOf(qKey) !== -1) return 400; // ...or appears inside
  317. var hits = qTokens.filter(function (q) {
  318. return it.tokens.some(function (t) {
  319. // German compounds mean one word often sits inside another ("Socken"
  320. // in "Skisocken"), so substrings count - but only between tokens of
  321. // real length, else the "T" of "T-Shirt" matches half the table.
  322. return t === q || (q.length >= 3 && t.length >= 3 &&
  323. (t.indexOf(q) !== -1 || q.indexOf(t) !== -1));
  324. });
  325. }).length;
  326. // All words matched, just in another order ("jacket rain" -> "Rain jacket").
  327. if (hits) return hits === qTokens.length ? 300 + hits : 100 * hits;
  328. // Typos are the usual way a duplicate sneaks in ("sonnenbrile").
  329. var slack = qKey.length >= 8 ? 2 : 1;
  330. if (Math.abs(it.key.length - qKey.length) <= slack && lev(it.key, qKey, slack) <= slack) {
  331. return 200;
  332. }
  333. return 0;
  334. }
  335. function esc(s) {
  336. return s.replace(/[&<>"]/g, function (c) {
  337. return {'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;'}[c];
  338. });
  339. }
  340. // Keeps `hint` in sync with what is typed into `input`. A rename passes its
  341. // own itemID as `selfId` so the row never flags itself, and stays quiet until
  342. // the name actually differs from the one it started with.
  343. function attach(input, hint, selfId) {
  344. if (!input || !hint) return;
  345. var baseline = key(input.defaultValue || '');
  346. var baseClass = hint.className;
  347. function render() {
  348. var qKey = key(input.value.trim());
  349. if (qKey.length < 2 || (baseline && qKey === baseline)) { hint.hidden = true; return; }
  350. var qTokens = qKey.split(' ');
  351. var hits = ITEMS.filter(function (it) { return it.id !== selfId; })
  352. .map(function (it) { return {it: it, s: score(qTokens, qKey, it)}; })
  353. .filter(function (h) { return h.s > 0; })
  354. .sort(function (a, b) {
  355. return b.s - a.s || a.it.name.localeCompare(b.it.name);
  356. });
  357. if (!hits.length) { hint.hidden = true; return; }
  358. var exact = hits[0].s === 1000;
  359. var shown = hits.slice(0, MAX_HITS);
  360. var head = exact
  361. ? '⚠ “' + esc(hits[0].it.name) + '” already exists'
  362. : shown.length + ' similar item' + (shown.length === 1 ? '' : 's') + ' already in the database';
  363. var html = '<div class="dup-hint-head">' + head + '</div>';
  364. shown.forEach(function (h) {
  365. html += '<div class="dup-row">'
  366. + '<span class="dup-id">#' + h.it.id + '</span>'
  367. + '<span class="dup-name">' + esc(h.it.name) + '</span>'
  368. + '<span class="badge badge-group">' + esc(h.it.group) + '</span>'
  369. + '</div>';
  370. });
  371. if (hits.length > shown.length) {
  372. html += '<div class="dup-row dup-id">+ ' + (hits.length - shown.length) + ' more</div>';
  373. }
  374. hint.className = baseClass + (exact ? ' exact' : '');
  375. hint.innerHTML = html;
  376. hint.hidden = false;
  377. }
  378. input.addEventListener('input', render);
  379. input.addEventListener('focus', render);
  380. render();
  381. }
  382. attach(document.getElementById('new-item-name'), document.getElementById('dup-hint'), 0);
  383. Array.prototype.forEach.call(
  384. document.querySelectorAll('input[data-dup-hint]'),
  385. function (input) {
  386. attach(input, document.getElementById(input.dataset.dupHint), +input.dataset.itemId);
  387. });
  388. })();
  389. </script>
  390. <?php admin_foot(); ?>