diff --git a/public/app.js b/public/app.js
index 5bff932..e0fa939 100644
--- a/public/app.js
+++ b/public/app.js
@@ -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) => `
${escapeHtml(t)}
`).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);
}
diff --git a/public/styles.css b/public/styles.css
index 11d7982..9e3dcdb 100644
--- a/public/styles.css
+++ b/public/styles.css
@@ -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); }