Add tag autocomplete to the upload/edit tag inputs
All checks were successful
build-and-push / docker (push) Successful in 27s

Suggests existing library tags matching the token being typed (after the
last comma), with click or arrow-key/Enter selection; excludes tags
already entered.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
dlawler489 2026-06-23 11:43:23 +10:00
parent a2e08ecd03
commit d3410e19ec
2 changed files with 75 additions and 0 deletions

View file

@ -335,6 +335,8 @@ function setupEventListeners() {
});
document.getElementById('deleteFileBtn').addEventListener('click', handleDeleteModel);
setupTagAutocomplete(document.getElementById('fileTags'));
setupTagAutocomplete(document.getElementById('editTags'));
document.getElementById('regenThumbBtn').addEventListener('click', handleRegenThumbnail);
document.getElementById('refreshLicenseBtn').addEventListener('click', handleRefreshMetadata);
document.getElementById('editModelBtn').addEventListener('click', openEditModal);
@ -932,6 +934,69 @@ async function handleDeleteModel() {
}
// ---------------------------------------------------------------- utils
// All distinct tags currently in the library, sorted.
function knownTags() {
const set = new Set();
models.forEach((m) => (m.tags || []).forEach((t) => set.add(t)));
return [...set].sort((a, b) => a.localeCompare(b));
}
// Autocomplete for a comma-separated tag input: suggests existing tags that
// match the token currently being typed (after the last comma).
function setupTagAutocomplete(input) {
if (!input) return;
const box = document.createElement('div');
box.className = 'tag-suggest';
box.style.display = 'none';
input.parentNode.style.position = 'relative';
input.parentNode.appendChild(box);
let active = -1;
const parts = () => input.value.split(',');
const currentToken = () => parts().pop().trim().toLowerCase();
const hide = () => { box.style.display = 'none'; active = -1; };
function choose(tag) {
const p = parts().map((x) => x.trim());
p[p.length - 1] = tag;
input.value = p.join(', ') + ', ';
hide();
input.focus();
}
function render() {
const tok = currentToken();
if (!tok) return hide();
const taken = new Set(parts().map((x) => x.trim().toLowerCase()));
const matches = knownTags().filter((t) => t.toLowerCase().includes(tok) && !taken.has(t.toLowerCase())).slice(0, 8);
if (!matches.length) return hide();
active = -1;
box.innerHTML = matches.map((t) => `<div class="tag-suggest-item" data-tag="${escapeHtml(t)}">${escapeHtml(t)}</div>`).join('');
box.style.top = `${input.offsetTop + input.offsetHeight + 2}px`;
box.style.left = `${input.offsetLeft}px`;
box.style.width = `${input.offsetWidth}px`;
box.style.display = '';
}
input.addEventListener('input', render);
input.addEventListener('focus', render);
input.addEventListener('blur', () => setTimeout(hide, 150));
input.addEventListener('keydown', (e) => {
const items = [...box.querySelectorAll('.tag-suggest-item')];
if (box.style.display === 'none' || !items.length) return;
if (e.key === 'ArrowDown') { e.preventDefault(); active = (active + 1) % items.length; }
else if (e.key === 'ArrowUp') { e.preventDefault(); active = (active - 1 + items.length) % items.length; }
else if (e.key === 'Enter' && active >= 0) { e.preventDefault(); choose(items[active].dataset.tag); return; }
else if (e.key === 'Escape') { hide(); return; }
else return;
items.forEach((it, i) => it.classList.toggle('active', i === active));
});
box.addEventListener('mousedown', (e) => {
const it = e.target.closest('.tag-suggest-item');
if (it) { e.preventDefault(); choose(it.dataset.tag); }
});
}
function splitTags(value) {
return value.split(',').map((t) => t.trim()).filter(Boolean);
}

View file

@ -269,3 +269,13 @@ label { display: block; font-size: 13px; font-weight: 500; color: var(--text-mut
.license-name { font-size: 13px; color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.license-name em { color: var(--text-dim); font-style: normal; }
.license-select { width: auto; flex-shrink: 0; padding: 6px 10px; font-size: 13px; }
/* ---- tag autocomplete ---- */
.tag-suggest {
position: absolute; z-index: 130;
background: var(--surface-2); border: 1px solid var(--border-strong);
border-radius: var(--radius-sm); max-height: 200px; overflow-y: auto;
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.45);
}
.tag-suggest-item { padding: 7px 11px; font-size: 13px; cursor: pointer; color: var(--text-muted); }
.tag-suggest-item:hover, .tag-suggest-item.active { background: var(--accent-soft); color: var(--accent); }