items.php 12 KB

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