|
|
@@ -47,6 +47,26 @@ 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);}
|
|
|
+</style>
|
|
|
+
|
|
|
<div class="card">
|
|
|
<h2>Add New Item</h2>
|
|
|
<form method="POST">
|
|
|
@@ -54,7 +74,8 @@ show_alerts();
|
|
|
<div class="form-row">
|
|
|
<div>
|
|
|
<label>Item Name</label>
|
|
|
- <input type="text" name="itemName" placeholder="e.g. Rain jacket" required>
|
|
|
+ <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>
|
|
|
@@ -100,7 +121,14 @@ show_alerts();
|
|
|
<td style="color:var(--muted)"><?= $id ?></td>
|
|
|
<td>
|
|
|
<div class="view"><?= htmlspecialchars($item['itemName']) ?></div>
|
|
|
- <div class="edit"><input type="text" name="itemName" form="edit-form-<?= $id ?>" value="<?= htmlspecialchars($item['itemName']) ?>" required></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>
|
|
|
@@ -147,4 +175,146 @@ function cancelEdit(id) {
|
|
|
}
|
|
|
</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(); ?>
|