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.
This commit is contained in:
1 parent
bf75c9fe8e
commit
c4aa345914
4 files changed
+183
-3
No files matched your search
@@ -476,3 +476,26 @@ body {
|
|||||||
font-family: var(--serif);
|
font-family: var(--serif);
|
||||||
margin-top: 16px;
|
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);
|
||||||
|
}
|
||||||
@@ -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 <input>, 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);
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -105,9 +105,9 @@
|
|||||||
|
|
||||||
<div class="bulkbar" id="bulkbar">
|
<div class="bulkbar" id="bulkbar">
|
||||||
<span class="sel" id="sel">0 selected</span>
|
<span class="sel" id="sel">0 selected</span>
|
||||||
<input type="text" id="bulk-add" placeholder="add tags" autocomplete="off">
|
<input type="text" id="bulk-add" placeholder="add tags" autocomplete="off" data-tagcomplete>
|
||||||
<button class="btn" id="btn-add">Add</button>
|
<button class="btn" id="btn-add">Add</button>
|
||||||
<input type="text" id="bulk-remove" placeholder="remove tags" autocomplete="off">
|
<input type="text" id="bulk-remove" placeholder="remove tags" autocomplete="off" data-tagcomplete>
|
||||||
<button class="btn" id="btn-remove">Remove</button>
|
<button class="btn" id="btn-remove">Remove</button>
|
||||||
<button class="btn danger" id="btn-del-sel">Delete selected</button>
|
<button class="btn danger" id="btn-del-sel">Delete selected</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -118,6 +118,7 @@
|
|||||||
|
|
||||||
<div id="toast"></div>
|
<div id="toast"></div>
|
||||||
|
|
||||||
|
<script src="/static/tagcomplete.js"></script>
|
||||||
<script>
|
<script>
|
||||||
const list = document.getElementById('list');
|
const list = document.getElementById('list');
|
||||||
const countEl = document.getElementById('count');
|
const countEl = document.getElementById('count');
|
||||||
@@ -166,6 +167,7 @@
|
|||||||
// Set values via property to avoid HTML-escaping issues in attributes.
|
// Set values via property to avoid HTML-escaping issues in attributes.
|
||||||
el.querySelector('.f-caption').value = p.caption;
|
el.querySelector('.f-caption').value = p.caption;
|
||||||
el.querySelector('.f-tags').value = (p.tags || []).join(', ');
|
el.querySelector('.f-tags').value = (p.tags || []).join(', ');
|
||||||
|
if (window.TagComplete) window.TagComplete.attach(el.querySelector('.f-tags'));
|
||||||
|
|
||||||
el.querySelector('.pick').addEventListener('change', refreshBulk);
|
el.querySelector('.pick').addEventListener('change', refreshBulk);
|
||||||
el.querySelector('.f-save').addEventListener('click', () => save(el, p.slug));
|
el.querySelector('.f-save').addEventListener('click', () => save(el, p.slug));
|
||||||
@@ -203,6 +205,7 @@
|
|||||||
form.append('tags', el.querySelector('.f-tags').value);
|
form.append('tags', el.querySelector('.f-tags').value);
|
||||||
try {
|
try {
|
||||||
await post('/api/edit', form);
|
await post('/api/edit', form);
|
||||||
|
if (window.TagComplete) window.TagComplete.invalidate();
|
||||||
flash('Saved ' + slug, true);
|
flash('Saved ' + slug, true);
|
||||||
} catch (err) { flash('Save failed: ' + err.message, false); }
|
} catch (err) { flash('Save failed: ' + err.message, false); }
|
||||||
}
|
}
|
||||||
@@ -254,6 +257,7 @@
|
|||||||
const res = await post('/api/tags', form);
|
const res = await post('/api/tags', form);
|
||||||
const j = await res.json().catch(() => ({}));
|
const j = await res.json().catch(() => ({}));
|
||||||
if (!j.updated) { flash('No posts updated', false); return; }
|
if (!j.updated) { flash('No posts updated', false); return; }
|
||||||
|
if (window.TagComplete) window.TagComplete.invalidate();
|
||||||
document.getElementById(field).value = '';
|
document.getElementById(field).value = '';
|
||||||
flash('Updated tags on ' + j.updated + ' post(s)', true);
|
flash('Updated tags on ' + j.updated + ' post(s)', true);
|
||||||
await load();
|
await load();
|
||||||
|
|||||||
@@ -163,13 +163,14 @@
|
|||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="tags">Tags</label>
|
<label for="tags">Tags</label>
|
||||||
<input type="text" id="tags" placeholder="comma-separated, lowercase" autocomplete="off">
|
<input type="text" id="tags" placeholder="comma-separated, lowercase" autocomplete="off" data-tagcomplete>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button class="submit-btn" id="submit-btn" disabled>Upload photo</button>
|
<button class="submit-btn" id="submit-btn" disabled>Upload photo</button>
|
||||||
<div id="feedback"></div>
|
<div id="feedback"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script src="/static/tagcomplete.js"></script>
|
||||||
<script>
|
<script>
|
||||||
const fileInput = document.getElementById('file-input');
|
const fileInput = document.getElementById('file-input');
|
||||||
const preview = document.getElementById('preview');
|
const preview = document.getElementById('preview');
|
||||||
|
|||||||
Reference in new issue
Block a user