items.php 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324
  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. FROM items i
  39. JOIN item_groups ig ON ig.groupID = i.groupID
  40. ORDER BY ig.sortOrder, i.sortOrder, i.itemID
  41. ")->fetchAll();
  42. admin_head('Items', 'items.php');
  43. show_alerts();
  44. ?>
  45. <h1>Items</h1>
  46. <style>
  47. /* Advisory duplicate hint under an item-name field. Never blocks a save. */
  48. .dup-hint{margin:-.55rem 0 .9rem;border:1.5px solid var(--border);border-radius:var(--radius);
  49. background:var(--bg);padding:.4rem 0;font-size:.85rem;}
  50. .dup-hint-head{padding:.15rem .8rem .3rem;font-size:.72rem;font-weight:600;letter-spacing:.06em;
  51. text-transform:uppercase;color:var(--muted);}
  52. .dup-hint.exact{border-color:rgba(232,197,71,.45);}
  53. .dup-hint.exact .dup-hint-head{color:var(--accent);}
  54. .dup-row{display:flex;gap:.5rem;align-items:center;padding:.25rem .8rem;}
  55. .dup-name{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
  56. .dup-id{color:var(--muted);font-size:.78rem;}
  57. /* In the table the hint floats, so a row does not jump in height while editing.
  58. The cell's own overflow:hidden would clip it, hence the override. */
  59. .tbl-fixed tr[data-editing="true"] td{overflow:visible;}
  60. .dup-anchor{position:relative;}
  61. .dup-hint-float{position:absolute;z-index:30;top:calc(100% + 4px);left:0;margin:0;
  62. min-width:100%;width:max-content;max-width:min(420px,60vw);
  63. background:var(--surface);box-shadow:0 8px 24px rgba(0,0,0,.45);}
  64. </style>
  65. <div class="card">
  66. <h2>Add New Item</h2>
  67. <form method="POST">
  68. <?= csrf_field() ?>
  69. <input type="hidden" name="action" value="add">
  70. <div class="form-row">
  71. <div>
  72. <label>Item Name</label>
  73. <input type="text" name="itemName" id="new-item-name" autocomplete="off" placeholder="e.g. Rain jacket" required>
  74. <div class="dup-hint" id="dup-hint" hidden></div>
  75. </div>
  76. <div>
  77. <label>Group</label>
  78. <select name="groupID" required>
  79. <option value="">– select –</option>
  80. <?php foreach ($igroups as $g): ?>
  81. <option value="<?= $g['groupID'] ?>"><?= htmlspecialchars($g['groupName']) ?></option>
  82. <?php endforeach; ?>
  83. </select>
  84. </div>
  85. </div>
  86. <div>
  87. <label>Sort Order</label>
  88. <input type="number" name="sortOrder" value="0" min="0" style="max-width:120px;">
  89. </div>
  90. <div style="display:flex;gap:.5rem;margin-top:.25rem;">
  91. <button type="submit" class="btn btn-primary">➕ Add</button>
  92. </div>
  93. </form>
  94. </div>
  95. <?php foreach ($items as $item): ?>
  96. <form id="edit-form-<?= $item['itemID'] ?>" method="POST">
  97. <?= csrf_field() ?>
  98. <input type="hidden" name="action" value="edit">
  99. <input type="hidden" name="itemID" value="<?= $item['itemID'] ?>">
  100. </form>
  101. <?php endforeach; ?>
  102. <table class="tbl tbl-fixed">
  103. <colgroup>
  104. <col style="width:60px">
  105. <col>
  106. <col style="width:180px">
  107. <col style="width:100px">
  108. <col style="width:200px">
  109. </colgroup>
  110. <thead><tr><th>#</th><th>Name</th><th>Group</th><th>Sort</th><th></th></tr></thead>
  111. <tbody>
  112. <?php foreach ($items as $item):
  113. $id = $item['itemID'];
  114. ?>
  115. <tr id="row-<?= $id ?>" data-editing="false">
  116. <td style="color:var(--muted)"><?= $id ?></td>
  117. <td>
  118. <div class="view"><?= htmlspecialchars($item['itemName']) ?></div>
  119. <div class="edit">
  120. <div class="dup-anchor">
  121. <input type="text" name="itemName" form="edit-form-<?= $id ?>" autocomplete="off"
  122. value="<?= htmlspecialchars($item['itemName']) ?>"
  123. data-dup-hint="dup-hint-<?= $id ?>" data-item-id="<?= $id ?>" required>
  124. <div class="dup-hint dup-hint-float" id="dup-hint-<?= $id ?>" hidden></div>
  125. </div>
  126. </div>
  127. </td>
  128. <td>
  129. <div class="view"><span class="badge badge-group"><?= htmlspecialchars($item['groupName']) ?></span></div>
  130. <div class="edit">
  131. <select name="groupID" form="edit-form-<?= $id ?>" required>
  132. <?php foreach ($igroups as $g): ?>
  133. <option value="<?= $g['groupID'] ?>" <?= $item['groupID'] == $g['groupID'] ? 'selected' : '' ?>><?= htmlspecialchars($g['groupName']) ?></option>
  134. <?php endforeach; ?>
  135. </select>
  136. </div>
  137. </td>
  138. <td>
  139. <div class="view"><?= $item['sortOrder'] ?></div>
  140. <div class="edit"><input type="number" name="sortOrder" form="edit-form-<?= $id ?>" value="<?= (int)$item['sortOrder'] ?>" min="0"></div>
  141. </td>
  142. <td>
  143. <div class="view actions-buttons">
  144. <button type="button" onclick="startEdit(<?= $id ?>)" class="btn btn-sm btn-teal">Edit</button>
  145. <form method="POST" onsubmit="return confirm('Delete this item?')">
  146. <?= csrf_field() ?>
  147. <input type="hidden" name="action" value="delete">
  148. <input type="hidden" name="itemID" value="<?= $id ?>">
  149. <button class="btn btn-sm btn-danger">Delete</button>
  150. </form>
  151. </div>
  152. <div class="edit actions-buttons">
  153. <button type="submit" form="edit-form-<?= $id ?>" class="btn btn-sm btn-primary">💾 Save</button>
  154. <button type="button" onclick="cancelEdit(<?= $id ?>)" class="btn btn-sm btn-secondary">Cancel</button>
  155. </div>
  156. </td>
  157. </tr>
  158. <?php endforeach; ?>
  159. </tbody>
  160. </table>
  161. <script>
  162. function startEdit(id) {
  163. var row = document.getElementById('row-' + id);
  164. row.dataset.editing = 'true';
  165. var input = row.querySelector('.edit input[name="itemName"]');
  166. if (input) input.focus();
  167. }
  168. function cancelEdit(id) {
  169. document.getElementById('row-' + id).dataset.editing = 'false';
  170. }
  171. </script>
  172. <script>
  173. // Duplicate hints: purely advisory. They never block a save - they only show
  174. // which existing items resemble the name being typed, both on the add form and
  175. // on every inline rename.
  176. (function () {
  177. var ITEMS = <?= json_encode(array_map(function ($i) {
  178. return ['id' => (int)$i['itemID'], 'name' => $i['itemName'], 'group' => $i['groupName']];
  179. }, $items), JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT) ?>;
  180. var MAX_HITS = 8;
  181. // Lower-cased, stripped of diacritics and punctuation, so "Rain-Jacket" and
  182. // "rain jacket" collapse onto the same key.
  183. function norm(s) {
  184. return s.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '')
  185. .replace(/[^a-z0-9]+/g, ' ').replace(/\s+/g, ' ').trim();
  186. }
  187. // Crude singular, enough to make "socks" and "sock" match.
  188. function singular(t) {
  189. return t.length > 3 && t.slice(-1) === 's' ? t.slice(0, -1) : t;
  190. }
  191. function tokens(s) {
  192. return norm(s).split(' ').filter(Boolean).map(singular);
  193. }
  194. function key(s) {
  195. return tokens(s).join(' ');
  196. }
  197. ITEMS.forEach(function (it) {
  198. it.key = key(it.name);
  199. it.tokens = it.key.split(' ').filter(Boolean);
  200. });
  201. // Levenshtein distance, abandoned as soon as it exceeds `max`.
  202. function lev(a, b, max) {
  203. var prev = [], cur = [], i, j;
  204. for (j = 0; j <= b.length; j++) prev[j] = j;
  205. for (i = 1; i <= a.length; i++) {
  206. cur = [i];
  207. var best = i;
  208. for (j = 1; j <= b.length; j++) {
  209. cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1,
  210. prev[j - 1] + (a.charAt(i - 1) === b.charAt(j - 1) ? 0 : 1));
  211. if (cur[j] < best) best = cur[j];
  212. }
  213. if (best > max) return max + 1;
  214. prev = cur;
  215. }
  216. return prev[b.length];
  217. }
  218. // Higher is more similar; 0 means "not worth showing".
  219. function score(qTokens, qKey, it) {
  220. if (!qKey) return 0;
  221. if (it.key === qKey) return 1000; // same item, different spelling
  222. if (it.key.indexOf(qKey) === 0) return 500; // typed name is a prefix
  223. if (it.key.indexOf(qKey) !== -1) return 400; // ...or appears inside
  224. var hits = qTokens.filter(function (q) {
  225. return it.tokens.some(function (t) {
  226. // German compounds mean one word often sits inside another ("Socken"
  227. // in "Skisocken"), so substrings count - but only between tokens of
  228. // real length, else the "T" of "T-Shirt" matches half the table.
  229. return t === q || (q.length >= 3 && t.length >= 3 &&
  230. (t.indexOf(q) !== -1 || q.indexOf(t) !== -1));
  231. });
  232. }).length;
  233. // All words matched, just in another order ("jacket rain" -> "Rain jacket").
  234. if (hits) return hits === qTokens.length ? 300 + hits : 100 * hits;
  235. // Typos are the usual way a duplicate sneaks in ("sonnenbrile").
  236. var slack = qKey.length >= 8 ? 2 : 1;
  237. if (Math.abs(it.key.length - qKey.length) <= slack && lev(it.key, qKey, slack) <= slack) {
  238. return 200;
  239. }
  240. return 0;
  241. }
  242. function esc(s) {
  243. return s.replace(/[&<>"]/g, function (c) {
  244. return {'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;'}[c];
  245. });
  246. }
  247. // Keeps `hint` in sync with what is typed into `input`. A rename passes its
  248. // own itemID as `selfId` so the row never flags itself, and stays quiet until
  249. // the name actually differs from the one it started with.
  250. function attach(input, hint, selfId) {
  251. if (!input || !hint) return;
  252. var baseline = key(input.defaultValue || '');
  253. var baseClass = hint.className;
  254. function render() {
  255. var qKey = key(input.value.trim());
  256. if (qKey.length < 2 || (baseline && qKey === baseline)) { hint.hidden = true; return; }
  257. var qTokens = qKey.split(' ');
  258. var hits = ITEMS.filter(function (it) { return it.id !== selfId; })
  259. .map(function (it) { return {it: it, s: score(qTokens, qKey, it)}; })
  260. .filter(function (h) { return h.s > 0; })
  261. .sort(function (a, b) {
  262. return b.s - a.s || a.it.name.localeCompare(b.it.name);
  263. });
  264. if (!hits.length) { hint.hidden = true; return; }
  265. var exact = hits[0].s === 1000;
  266. var shown = hits.slice(0, MAX_HITS);
  267. var head = exact
  268. ? '⚠ “' + esc(hits[0].it.name) + '” already exists'
  269. : shown.length + ' similar item' + (shown.length === 1 ? '' : 's') + ' already in the database';
  270. var html = '<div class="dup-hint-head">' + head + '</div>';
  271. shown.forEach(function (h) {
  272. html += '<div class="dup-row">'
  273. + '<span class="dup-id">#' + h.it.id + '</span>'
  274. + '<span class="dup-name">' + esc(h.it.name) + '</span>'
  275. + '<span class="badge badge-group">' + esc(h.it.group) + '</span>'
  276. + '</div>';
  277. });
  278. if (hits.length > shown.length) {
  279. html += '<div class="dup-row dup-id">+ ' + (hits.length - shown.length) + ' more</div>';
  280. }
  281. hint.className = baseClass + (exact ? ' exact' : '');
  282. hint.innerHTML = html;
  283. hint.hidden = false;
  284. }
  285. input.addEventListener('input', render);
  286. input.addEventListener('focus', render);
  287. render();
  288. }
  289. attach(document.getElementById('new-item-name'), document.getElementById('dup-hint'), 0);
  290. Array.prototype.forEach.call(
  291. document.querySelectorAll('input[data-dup-hint]'),
  292. function (input) {
  293. attach(input, document.getElementById(input.dataset.dupHint), +input.dataset.itemId);
  294. });
  295. })();
  296. </script>
  297. <?php admin_foot(); ?>