From c4aa34591428537a307f4dd6ad720ce90a40c9e1 Mon Sep 17 00:00:00 2001 From: puttaalu Date: Sun, 18 Oct 2026 22:39:13 +0200 Subject: [PATCH] Autocomplete existing tags in the upload and manage forms A small shared widget (static/tagcomplete.js) completes the tag after the last comma from the existing tag list, keeping earlier tags intact. It is wired to every comma-separated tag input: the upload form, and the manage page's per-row edit field and bulk add/remove fields. Suggestions come from /tags.json, which the generator already writes on every build and nginx serves statically, so no server change is needed; the list is fetched once, cached, and invalidated after an edit that may add a tag. Keyboard navigation (up/down, enter/tab, escape) and click selection are supported. --- static/style.css | 23 +++++++ static/tagcomplete.js | 152 ++++++++++++++++++++++++++++++++++++++++++ templates/manage.html | 8 ++- templates/upload.html | 3 +- 4 files changed, 183 insertions(+), 3 deletions(-) create mode 100644 static/tagcomplete.js diff --git a/static/style.css b/static/style.css index d7cee56..5b08429 100644 --- a/static/style.css +++ b/static/style.css @@ -476,3 +476,26 @@ body { font-family: var(--serif); margin-top: 16px; } + +/* Tag autocomplete dropdown (tagcomplete.js), shared by upload and manage. */ +.tagac-menu { + position: absolute; + z-index: 50; + background: var(--bg1); + border: 1px solid var(--bg2); + max-height: 220px; + overflow-y: auto; + box-shadow: 0 6px 18px rgba(0, 0, 0, 0.4); +} +.tagac-item { + padding: 7px 11px; + font-family: var(--serif); + font-size: 14px; + color: var(--fg2); + cursor: pointer; +} +.tagac-item.sel, +.tagac-item:hover { + background: var(--bg2); + color: var(--fg); +} diff --git a/static/tagcomplete.js b/static/tagcomplete.js new file mode 100644 index 0000000..1c4acae --- /dev/null +++ b/static/tagcomplete.js @@ -0,0 +1,152 @@ +// Per-token tag autocomplete for comma-separated tag inputs, shared by the +// upload and manage pages. Suggestions come from /tags.json (regenerated on +// every build), so new tags appear on the next load. Only the token after the +// last comma is completed; earlier tags in the field are left untouched. +// +// Usage: add data-tagcomplete to an , or call TagComplete.attach(input) +// for inputs created dynamically. TagComplete.invalidate() drops the cached +// tag list after an edit that may have introduced new tags. +(function () { + let tags = null; // cached list of tag names + let inflight = null; + + const menu = document.createElement("div"); + menu.className = "tagac-menu"; + menu.style.display = "none"; + menu.setAttribute("role", "listbox"); + document.body.appendChild(menu); + + let items = []; // currently shown suggestions + let sel = -1; // highlighted index + + function loadTags() { + if (tags) return Promise.resolve(tags); + if (inflight) return inflight; + inflight = fetch("/tags.json", { headers: { Accept: "application/json" } }) + .then((r) => (r.ok ? r.json() : [])) + .then((list) => { + tags = (list || []).map((t) => t.tag); + return tags; + }) + .catch(() => { + tags = []; + return tags; + }); + return inflight; + } + + // The token being typed is the text after the last comma. + function lastToken(value) { + return value.slice(value.lastIndexOf(",") + 1).replace(/^\s+/, ""); + } + + function replaceLastToken(value, tag) { + const i = value.lastIndexOf(","); + const head = i < 0 ? "" : value.slice(0, i + 1) + " "; + return head + tag + ", "; + } + + function presentTags(value) { + return value + .split(",") + .map((s) => s.trim().toLowerCase()) + .filter(Boolean); + } + + function hide() { + menu.style.display = "none"; + sel = -1; + items = []; + } + + function highlight() { + [...menu.children].forEach((c, i) => c.classList.toggle("sel", i === sel)); + if (sel >= 0 && menu.children[sel]) { + menu.children[sel].scrollIntoView({ block: "nearest" }); + } + } + + function render(input) { + const token = lastToken(input.value).toLowerCase(); + if (!token) { + hide(); + return; + } + const present = new Set(presentTags(input.value)); + items = (tags || []) + .filter((t) => t.toLowerCase().includes(token) && !present.has(t.toLowerCase())) + .slice(0, 8); + if (items.length === 0) { + hide(); + return; + } + sel = 0; + menu.innerHTML = ""; + items.forEach((t, idx) => { + const row = document.createElement("div"); + row.className = "tagac-item" + (idx === 0 ? " sel" : ""); + row.setAttribute("role", "option"); + row.textContent = t; + // mousedown (not click) so it fires before the input's blur. + row.addEventListener("mousedown", (e) => { + e.preventDefault(); + choose(input, idx); + }); + menu.appendChild(row); + }); + const r = input.getBoundingClientRect(); + menu.style.left = window.scrollX + r.left + "px"; + menu.style.top = window.scrollY + r.bottom + "px"; + menu.style.width = r.width + "px"; + menu.style.display = "block"; + } + + function choose(input, idx) { + if (idx < 0 || idx >= items.length) return; + input.value = replaceLastToken(input.value, items[idx]); + hide(); + input.focus(); + input.dispatchEvent(new Event("input", { bubbles: true })); + } + + function attach(input) { + if (!input || input.dataset.tagacBound) return; + input.dataset.tagacBound = "1"; + input.setAttribute("autocomplete", "off"); + input.addEventListener("focus", () => loadTags().then(() => render(input))); + input.addEventListener("input", () => loadTags().then(() => render(input))); + input.addEventListener("keydown", (e) => { + if (menu.style.display === "none" || items.length === 0) return; + if (e.key === "ArrowDown") { + e.preventDefault(); + sel = (sel + 1) % items.length; + highlight(); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + sel = (sel - 1 + items.length) % items.length; + highlight(); + } else if (e.key === "Enter" || e.key === "Tab") { + if (sel >= 0) { + e.preventDefault(); + choose(input, sel); + } + } else if (e.key === "Escape") { + hide(); + } + }); + // Delay so a mousedown on the menu is processed before hiding. + input.addEventListener("blur", () => setTimeout(hide, 120)); + } + + window.TagComplete = { + attach, + invalidate() { + tags = null; + inflight = null; + }, + }; + + document.addEventListener("DOMContentLoaded", () => { + document.querySelectorAll("input[data-tagcomplete]").forEach(attach); + }); +})(); diff --git a/templates/manage.html b/templates/manage.html index 0caca6c..7d87826 100644 --- a/templates/manage.html +++ b/templates/manage.html @@ -105,9 +105,9 @@
0 selected - + - +
@@ -118,6 +118,7 @@
+