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); }