diff --git a/webui/static/discover.js b/webui/static/discover.js index e85404bc..b601274f 100644 --- a/webui/static/discover.js +++ b/webui/static/discover.js @@ -5394,6 +5394,73 @@ function _artMapConnCount(n) { return c; } +// ── Watchlist state for the panel card ── +function _artMapIsWatched(n) { + if (!n) return false; + if (n.type === 'watchlist') return true; + const best = _artMapNodeBest(n); + return !!(best.id && _artMap._watchSet && _artMap._watchSet.has(best.id)); +} + +// The watchlist button's markup, reflecting current state (filled star + "On +// watchlist" when watched, outline + "Watchlist" when not). +function _artMapWatchBtnHtml(n) { + const watched = _artMapIsWatched(n); + const idArg = typeof n.id === 'number' ? n.id : `'${n.id}'`; + return ``; +} + +function _artMapRenderWatchBtn(n) { + const b = document.getElementById('artmap-card-watch'); + if (b) b.outerHTML = _artMapWatchBtnHtml(n); // inline onclick survives outerHTML swap +} + +// Toggle watchlist membership for a node, updating the cache + button in place. +async function _artMapToggleWatch(id) { + const n = (_artMap._nodeById || {})[id]; + if (!n) return; + const best = _artMapNodeBest(n); + if (!best.id) { showToast('No source id for this artist', 'error'); return; } + _artMap._watchSet = _artMap._watchSet || new Set(); + const watched = _artMapIsWatched(n); + try { + const resp = await fetch(watched ? '/api/watchlist/remove' : '/api/watchlist/add', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(watched ? { artist_id: best.id } : { artist_id: best.id, artist_name: n.name, source: best.source }), + }); + if (!resp.ok) { showToast('Failed to update watchlist', 'error'); return; } + if (watched) { + _artMap._watchSet.delete(best.id); + if (n.type === 'watchlist') n.type = 'similar'; + } else { + _artMap._watchSet.add(best.id); + } + showToast(watched ? `Removed ${n.name} from watchlist` : `Added ${n.name} to watchlist`, watched ? 'info' : 'success'); + _artMapRenderWatchBtn(n); + } catch (e) { + showToast('Failed to update watchlist', 'error'); + } +} + +// Lazily confirm watchlist membership from the server, then refresh the button. +function _artMapCheckWatched(n) { + const best = _artMapNodeBest(n); + _artMap._watchSet = _artMap._watchSet || new Set(); + _artMap._watchChecked = _artMap._watchChecked || new Set(); + if (!best.id || _artMap._watchChecked.has(best.id)) return; + fetch('/api/watchlist/check', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ artist_id: best.id }), + }).then(r => r.json()).then(d => { + _artMap._watchChecked.add(best.id); + if (d && d.success) { + if (d.is_watching) _artMap._watchSet.add(best.id); else _artMap._watchSet.delete(best.id); + if (_artMap._panelArtistId === n.id) _artMapRenderWatchBtn(n); + } + }).catch(() => { }); +} + function _artMapEnsurePanel() { const container = document.getElementById('artist-map-container'); if (!container) return null; @@ -5401,14 +5468,18 @@ function _artMapEnsurePanel() { if (!p) { p = document.createElement('div'); p.id = 'artmap-info-panel'; - p.style.cssText = `position:absolute;top:0;right:0;width:${_artMap._panelW}px;height:100%;` - + `background:linear-gradient(180deg,rgba(20,15,34,0.92),rgba(11,8,20,0.96));backdrop-filter:blur(16px);` - + `border-left:1px solid rgba(168,85,247,0.18);z-index:26;display:flex;flex-direction:column;` - + `color:#fff;overflow:hidden;box-shadow:-10px 0 36px rgba(0,0,0,0.45);font-size:13px;`; p.innerHTML = `
` + `
`; container.appendChild(p); } + // Start below the toolbar so it never covers the navbar (measured each call + // in case the toolbar height changes). + const tb = container.querySelector('.artist-map-toolbar'); + const top = tb ? tb.offsetHeight : 56; + p.style.cssText = `position:absolute;top:${top}px;right:0;width:${_artMap._panelW}px;height:calc(100% - ${top}px);` + + `background:linear-gradient(180deg,rgba(20,15,34,0.92),rgba(11,8,20,0.96));backdrop-filter:blur(16px);` + + `border-left:1px solid rgba(168,85,247,0.18);z-index:20;display:flex;flex-direction:column;` + + `color:#fff;overflow:hidden;box-shadow:-10px 0 36px rgba(0,0,0,0.45);font-size:13px;`; return p; } @@ -5524,7 +5595,7 @@ function _artMapPanelArtist(node) {
- + ${_artMapWatchBtnHtml(node)}
${best.id ? `Open artist page` : ''} `; @@ -5533,6 +5604,10 @@ function _artMapPanelArtist(node) { const c = document.getElementById('artmap-card-canvas'); if (c) { try { c.getContext('2d').drawImage(bmp, 0, 0, 120, 120); } catch (e) { /* ignore */ } } } + + // Confirm watchlist membership from the server (refreshes the button if it + // differs from the optimistic guess). + _artMapCheckWatched(node); } // Top-list / external entry: show a node's card by id (also ripples it on the map). @@ -7369,7 +7444,18 @@ function _artMapSetupInteraction(canvas) { _artMap.hoveredNode = _artMapHitTest(nx, ny); canvas.style.cursor = _artMap.hoveredNode ? 'pointer' : 'grab'; _artMapShowTooltip(e, _artMap.hoveredNode); - if (_artMap.hoveredNode) _artMapPanelArtist(_artMap.hoveredNode); // rich card in side panel + if (prev !== _artMap.hoveredNode) { + // Debounce the side-panel card: only swap to a bubble you've + // settled on for ~0.8s, so sweeping toward the panel doesn't keep + // changing the card on bubbles you pass over en route. + clearTimeout(_artMap._panelTimer); + if (_artMap.hoveredNode) { + const target = _artMap.hoveredNode; + _artMap._panelTimer = setTimeout(() => { + if (_artMap.hoveredNode === target) _artMapPanelArtist(target); + }, 800); + } + } if (prev !== _artMap.hoveredNode) { // Reset constellation highlight timer clearTimeout(_artMap._constellationTimer); @@ -7400,13 +7486,15 @@ function _artMapSetupInteraction(canvas) { isPanning = false; if (!wasDrag && clickStart) { - // It was a click — emit a water ripple (shoves nearby bubbles) and, - // if it landed on an artist, open it after the ripple kicks off. + // A click is a deliberate select — ripple it and pin its card in the + // side panel immediately (bypassing the hover debounce). The card's + // Details button opens the full modal; click no longer auto-opens it. const { nx, ny } = _artMapScreenToWorld(e, canvas); const node = _artMapHitTest(nx, ny); _artMapEmitRipple(node ? node.x : nx, node ? node.y : ny, node ? node._hue : null); - if (node && (node.spotify_id || node.itunes_id || node.deezer_id)) { - setTimeout(() => openYourArtistInfoModal_direct(node), 200); + if (node) { + clearTimeout(_artMap._panelTimer); + _artMapPanelArtist(node); } }