Artist Explorer: search-and-select instead of free text

The Explorer prompt accepted any loose text and explored whatever you typed.
Now it's a proper picker: type -> debounced search of the metadata source
(reuses /api/discover/build-playlist/search-artists — Hydrabase if active,
Spotify if configured, else the active metadata source) -> shows real artist
results with images -> click one to explore that resolved artist. Enter picks
the top match (never explores raw text); Escape/Cancel/backdrop close.

Pure frontend: rebuilds _showArtistMapSearchPrompt() (same Promise<name|null>
contract, so the caller is unchanged), reusing the playlist-builder's search
endpoint + picker styling. No backend change.
This commit is contained in:
BoulderBadgeDad 2026-06-02 23:16:47 -07:00
parent 1b21983ce7
commit b85392977c
2 changed files with 101 additions and 16 deletions

View file

@ -6431,14 +6431,22 @@ async function _openArtistMapExplorerWithName(name) {
}
function _showArtistMapSearchPrompt() {
// Search the metadata source and make the user PICK a real artist, rather
// than exploring whatever loose text they typed. Resolves with the chosen
// artist's resolved name (which the explorer hands to /artist-map/explore),
// or null if cancelled.
return new Promise(resolve => {
const existing = document.getElementById('artmap-search-prompt');
if (existing) existing.remove();
const overlay = document.createElement('div');
let done = false;
let overlay;
const finish = (val) => { if (done) return; done = true; if (overlay) overlay.remove(); resolve(val); };
overlay = document.createElement('div');
overlay.id = 'artmap-search-prompt';
overlay.className = 'modal-overlay';
overlay.onclick = (e) => { if (e.target === overlay) { overlay.remove(); resolve(null); } };
overlay.onclick = (e) => { if (e.target === overlay) finish(null); };
overlay.innerHTML = `
<div class="artmap-search-prompt-modal">
@ -6449,32 +6457,79 @@ function _showArtistMapSearchPrompt() {
</svg>
<div>
<h3>Artist Explorer</h3>
<p>Enter an artist to explore their connections</p>
<p>Search and pick an artist to explore</p>
</div>
</div>
<input type="text" id="artmap-explore-input" class="artmap-explore-input" placeholder="Artist name..." autofocus>
<div class="artmap-explore-search-wrap">
<input type="text" id="artmap-explore-input" class="artmap-explore-input"
placeholder="Search artists…" autocomplete="off" autofocus>
<div class="artmap-explore-spinner" id="artmap-explore-spinner" style="display:none">
<div class="watch-all-loading-spinner"></div>
</div>
</div>
<div class="artmap-explore-results" id="artmap-explore-results"></div>
<div class="artmap-search-prompt-actions">
<button class="btn btn--sm btn--secondary ya-header-btn" onclick="document.getElementById('artmap-search-prompt').remove()">Cancel</button>
<button class="btn btn--sm btn--secondary ya-header-btn ya-viewall-btn" id="artmap-explore-go">
<span>Explore</span>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M5 12h14"/><path d="M12 5l7 7-7 7"/></svg>
</button>
<button class="btn btn--sm btn--secondary ya-header-btn" id="artmap-explore-cancel">Cancel</button>
</div>
</div>
`;
document.body.appendChild(overlay);
const input = overlay.querySelector('#artmap-explore-input');
const goBtn = overlay.querySelector('#artmap-explore-go');
const results = overlay.querySelector('#artmap-explore-results');
const spinner = overlay.querySelector('#artmap-explore-spinner');
overlay.querySelector('#artmap-explore-cancel').onclick = () => finish(null);
const submit = () => {
const val = input.value.trim();
overlay.remove();
resolve(val || null);
const renderResults = (artists) => {
results.innerHTML = '';
if (!artists.length) {
results.innerHTML = '<div class="artmap-explore-empty">No artists found</div>';
return;
}
artists.forEach(a => {
const img = a.image_url || '/static/placeholder-album.png';
const row = document.createElement('div');
row.className = 'artmap-explore-result';
row.innerHTML = `
<img src="${escapeHtml(img)}" alt="" loading="lazy" onerror="this.src='/static/placeholder-album.png'">
<span class="artmap-explore-result-name">${escapeHtml(a.name)}</span>
<span class="artmap-explore-result-go">Explore &rarr;</span>`;
row.onclick = () => finish(a.name); // pick the resolved artist, not raw text
results.appendChild(row);
});
};
goBtn.onclick = submit;
input.addEventListener('keydown', (e) => { if (e.key === 'Enter') submit(); });
let timer = null;
let token = 0;
const doSearch = () => {
const q = input.value.trim();
if (!q) { results.innerHTML = ''; spinner.style.display = 'none'; clearTimeout(timer); return; }
clearTimeout(timer);
timer = setTimeout(async () => {
const myToken = ++token;
spinner.style.display = 'flex';
try {
const resp = await fetch(`/api/discover/build-playlist/search-artists?query=${encodeURIComponent(q)}`);
const data = await resp.json();
if (myToken !== token) return; // a newer keystroke superseded this
renderResults((data && data.success && Array.isArray(data.artists)) ? data.artists : []);
} catch (e) {
if (myToken === token) results.innerHTML = '<div class="artmap-explore-empty">Search failed — try again</div>';
} finally {
if (myToken === token) spinner.style.display = 'none';
}
}, 350);
};
input.addEventListener('input', doSearch);
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
const first = results.querySelector('.artmap-explore-result');
if (first) first.click(); // Enter = pick top match, never raw text
} else if (e.key === 'Escape') {
finish(null);
}
});
setTimeout(() => input.focus(), 50);
});
}

View file

@ -34358,6 +34358,36 @@ div.artist-hero-badge {
.artmap-explore-input::placeholder { color: rgba(255,255,255,0.2); }
.artmap-search-prompt-actions { display: flex; justify-content: flex-end; gap: 8px; }
/* Artist Explorer — search + select picker */
.artmap-explore-search-wrap { position: relative; }
.artmap-explore-search-wrap .artmap-explore-input { margin-bottom: 12px; }
.artmap-explore-spinner { position: absolute; right: 12px; top: 11px; pointer-events: none; }
.artmap-explore-spinner .watch-all-loading-spinner { width: 18px; height: 18px; border-width: 2px; }
.artmap-explore-results {
max-height: 320px; overflow-y: auto; display: flex; flex-direction: column; gap: 4px;
margin-bottom: 18px;
}
.artmap-explore-results:empty { margin-bottom: 0; }
.artmap-explore-result {
display: flex; align-items: center; gap: 12px; padding: 8px 10px; border-radius: 10px;
cursor: pointer; transition: background 0.15s ease;
}
.artmap-explore-result:hover { background: rgba(138,43,226,0.14); }
.artmap-explore-result img {
width: 40px; height: 40px; border-radius: 50%; object-fit: cover; flex: 0 0 auto;
background: rgba(255,255,255,0.06);
}
.artmap-explore-result-name {
flex: 1 1 auto; min-width: 0; color: #fff; font-size: 14px; font-weight: 600;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.artmap-explore-result-go {
flex: 0 0 auto; font-size: 11px; font-weight: 700; color: rgba(138,43,226,0.9);
opacity: 0; transition: opacity 0.15s ease;
}
.artmap-explore-result:hover .artmap-explore-result-go { opacity: 1; }
.artmap-explore-empty { padding: 18px; text-align: center; color: rgba(255,255,255,0.35); font-size: 13px; }
/* Genre picker modal */
.artmap-genre-picker-modal {
background: linear-gradient(165deg, #1e1e32 0%, #181828 100%);