Polish Wing It UX — dropdown on LB cards, position detection, better toasts

1. LB Discover page cards now use the dropdown (Download/Sync) instead
   of the old single button with full choice dialog
2. Dropdown position auto-flips downward when button is near viewport top
3. Dropdown centered on button instead of right-aligned
4. Sync completion toast now includes playlist name and  indicator
5. Download modal already shows  prefix on playlist name as indicator

All changes purely additive — existing flows unaffected.
This commit is contained in:
Broque Thomas 2026-04-01 20:30:34 -07:00
parent a9dd93b176
commit 0e3a217591
2 changed files with 285 additions and 28 deletions

View file

@ -11595,23 +11595,153 @@ async function exportPlaylistAsM3U(playlistId) {
// WING IT — Download without metadata discovery
// ==================================================================================
async function wingItDownload(tracks, playlistName, source = 'playlist', cardIdentifier = null) {
function _toggleWingItDropdown(btn, urlHash) {
// Remove any existing dropdown
const existing = document.querySelector('.wing-it-dropdown.visible');
if (existing) { existing.classList.remove('visible'); setTimeout(() => existing.remove(), 150); return; }
const wrap = btn.closest('.wing-it-wrap');
if (!wrap) return;
const dropdown = document.createElement('div');
dropdown.className = 'wing-it-dropdown';
dropdown.innerHTML = `
<button class="wing-it-dropdown-item" data-action="download">
<span class="wing-it-dropdown-icon"></span>
<span class="wing-it-dropdown-label">Download</span>
<span class="wing-it-dropdown-hint">Raw names</span>
</button>
<button class="wing-it-dropdown-item" data-action="sync">
<span class="wing-it-dropdown-icon">🔄</span>
<span class="wing-it-dropdown-label">Sync to Server</span>
<span class="wing-it-dropdown-hint">Best-effort</span>
</button>
`;
dropdown.querySelectorAll('.wing-it-dropdown-item').forEach(item => {
item.addEventListener('click', () => {
dropdown.classList.remove('visible');
setTimeout(() => dropdown.remove(), 150);
const action = item.dataset.action;
if (action === 'download') {
_wingItAction(urlHash, 'download');
} else {
_wingItAction(urlHash, 'sync');
}
});
});
// Flip dropdown direction if button is in the top portion of viewport
const btnRect = btn.getBoundingClientRect();
if (btnRect.top < 200) dropdown.classList.add('flip-down');
wrap.appendChild(dropdown);
requestAnimationFrame(() => dropdown.classList.add('visible'));
// Close on outside click
setTimeout(() => {
const closeHandler = e => {
if (!dropdown.contains(e.target) && e.target !== btn) {
dropdown.classList.remove('visible');
setTimeout(() => dropdown.remove(), 150);
document.removeEventListener('click', closeHandler);
}
};
document.addEventListener('click', closeHandler);
}, 50);
}
function _wingItAction(urlHash, action) {
if (urlHash) {
// Called from a modal — use _wingItFromModal logic
const state = listenbrainzPlaylistStates[urlHash] || youtubePlaylistStates[urlHash] || {};
const tracks = state.tracks || state.rawTracks || state.playlist?.tracks || [];
const name = state.playlistName || state.name || state.playlist?.name || 'Playlist';
const isTidal = state.is_tidal_playlist;
const isLB = state.is_listenbrainz_playlist;
const isBeatport = state.is_beatport_playlist;
const isDeezer = state.is_deezer_playlist;
const source = isLB ? 'ListenBrainz' : isTidal ? 'Tidal' : isDeezer ? 'Deezer' : isBeatport ? 'Beatport' : 'YouTube';
if (!tracks.length) {
showToast('No tracks available for Wing It', 'error');
return;
}
if (action === 'sync') {
// Sync inline — keep modal open
_wingItSyncFromModal(urlHash, tracks, name, isLB);
} else {
// Download — close modal, open download modal
const modal = document.getElementById(`youtube-discovery-modal-${urlHash}`);
if (modal) modal.remove();
const overlay = document.getElementById(`youtube-discovery-overlay-${urlHash}`);
if (overlay) overlay.remove();
wingItDownload(tracks, name, source, null, true);
}
}
}
async function _wingItSyncFromModal(urlHash, tracks, name, isLB) {
showToast('Starting Wing It sync...', 'info');
updateYouTubeModalButtons(urlHash, 'syncing');
try {
const syncTracks = tracks.map((t, i) => {
let artists = t.artists || [];
if (!Array.isArray(artists)) artists = [{ name: String(artists) }];
return {
id: t.id || t.source_track_id || `wing_it_${i}`,
name: t.name || t.track_name || 'Unknown',
artists: artists.map(a => typeof a === 'string' ? { name: a } : a),
album: typeof t.album === 'object' ? t.album : { name: t.album || t.album_name || '' },
duration_ms: t.duration_ms || 0,
};
});
const res = await fetch('/api/wing-it/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tracks: syncTracks, playlist_name: name })
});
const data = await res.json();
if (data.error) {
showToast(`Sync failed: ${data.error}`, 'error');
updateYouTubeModalButtons(urlHash, 'discovered');
return;
}
if (isLB) {
const state = listenbrainzPlaylistStates[urlHash];
if (state) state.syncPlaylistId = data.sync_playlist_id;
startListenBrainzSyncPolling(urlHash, data.sync_playlist_id);
} else {
startYouTubeSyncPolling(urlHash, data.sync_playlist_id);
}
} catch (e) {
showToast('Sync failed: ' + e.message, 'error');
updateYouTubeModalButtons(urlHash, 'discovered');
}
}
async function wingItDownload(tracks, playlistName, source = 'playlist', cardIdentifier = null, skipConfirm = false) {
if (!tracks || tracks.length === 0) {
showToast('No tracks to download', 'error');
return;
}
// Show choice: Download or Sync
const choice = await _showWingItChoiceDialog(tracks.length, source);
if (!choice) return;
if (!skipConfirm) {
// Show choice: Download or Sync (for LB card button which doesn't have dropdown)
const choice = await _showWingItChoiceDialog(tracks.length, source);
if (!choice) return;
if (choice === 'sync') {
await _wingItSync(tracks, playlistName, source, cardIdentifier);
return;
if (choice === 'sync') {
await _wingItSync(tracks, playlistName, source, cardIdentifier);
return;
}
}
// choice === 'download' — continue with download flow
// Normalize tracks to Spotify-compatible format
const formattedTracks = tracks.map(t => {
// Handle various artist formats
@ -11787,7 +11917,7 @@ function _pollWingItSyncProgress(syncPlaylistId, playlistName, cardPlaylistId) {
if (data.status === 'error') {
showToast(`Sync failed: ${data.error || 'Unknown error'}`, 'error');
} else {
showToast(`Sync complete — ${matched}/${total} tracks matched to server`, 'success');
showToast(`⚡ Wing It sync complete — "${playlistName}" created on server (${matched}/${total} tracks matched)`, 'success');
}
// Update card status display to show completion
@ -30823,7 +30953,7 @@ function getModalActionButtons(urlHash, phase, state = null) {
case 'discovering':
// Show start discovery button for fresh playlists
if (phase === 'fresh') {
const wingItBtn = ` <button class="modal-btn wing-it-btn" onclick="_wingItFromModal('${urlHash}')">⚡ Wing It</button>`;
const wingItBtn = ` <span class="wing-it-wrap"><button class="modal-btn wing-it-btn" onclick="_toggleWingItDropdown(this, '${urlHash}')">⚡ Wing It</button></span>`;
if (isListenBrainz) {
return `<button class="modal-btn modal-btn-primary" onclick="startListenBrainzDiscovery('${urlHash}')">🔍 Start Discovery</button>${wingItBtn}`;
@ -30895,8 +31025,8 @@ function getModalActionButtons(urlHash, phase, state = null) {
buttons += `<button class="modal-btn modal-btn-secondary" onclick="resetYouTubePlaylist('${urlHash}')">🔄 Rediscover</button>`;
}
// Wing It button — available in discovered phase for unmatched tracks
buttons += ` <button class="modal-btn wing-it-btn" onclick="_wingItFromModal('${urlHash}')">⚡ Wing It</button>`;
// Wing It button — available in discovered phase
buttons += ` <span class="wing-it-wrap"><button class="modal-btn wing-it-btn" onclick="_toggleWingItDropdown(this, '${urlHash}')">⚡ Wing It</button></span>`;
if (!buttons || buttons.trim().startsWith('<button class="modal-btn wing-it-btn"')) {
buttons = `<div class="modal-info"> No Spotify matches found.</div>` + buttons;
@ -31020,7 +31150,7 @@ function getModalActionButtons(urlHash, phase, state = null) {
}
// Wing It button
syncCompleteButtons += ` <button class="modal-btn wing-it-btn" onclick="_wingItFromModal('${urlHash}')">⚡ Wing It</button>`;
syncCompleteButtons += ` <span class="wing-it-wrap"><button class="modal-btn wing-it-btn" onclick="_toggleWingItDropdown(this, '${urlHash}')">⚡ Wing It</button></span>`;
return syncCompleteButtons;
@ -52150,12 +52280,14 @@ function buildListenBrainzPlaylistsHtml(playlists, tabId) {
<span class="button-icon"></span>
<span class="button-text">Download</span>
</button>
<span class="wing-it-wrap">
<button class="action-button wing-it-btn-sm"
onclick="_wingItFromLBCard('${identifier}', '${escapeForInlineJs(title)}')"
title="Download using raw track names — no metadata discovery">
onclick="_toggleWingItDropdownLB(this, '${identifier}', '${escapeForInlineJs(title)}')"
title="Download or sync using raw track names — no metadata discovery">
<span class="button-icon"></span>
<span class="button-text">Wing It</span>
</button>
</span>
<button class="action-button primary"
id="${playlistId}-sync-btn"
onclick="startListenBrainzPlaylistSync('${identifier}')"
@ -52426,7 +52558,65 @@ function displayListenBrainzTracks(tracks, playlistId) {
playlistContainer.innerHTML = html;
}
function _toggleWingItDropdownLB(btn, identifier, title) {
const existing = document.querySelector('.wing-it-dropdown.visible');
if (existing) { existing.classList.remove('visible'); setTimeout(() => existing.remove(), 150); return; }
const wrap = btn.closest('.wing-it-wrap');
if (!wrap) return;
const dropdown = document.createElement('div');
dropdown.className = 'wing-it-dropdown';
dropdown.innerHTML = `
<button class="wing-it-dropdown-item" data-action="download">
<span class="wing-it-dropdown-icon"></span>
<span class="wing-it-dropdown-label">Download</span>
<span class="wing-it-dropdown-hint">Raw names</span>
</button>
<button class="wing-it-dropdown-item" data-action="sync">
<span class="wing-it-dropdown-icon">🔄</span>
<span class="wing-it-dropdown-label">Sync to Server</span>
<span class="wing-it-dropdown-hint">Best-effort</span>
</button>
`;
dropdown.querySelectorAll('.wing-it-dropdown-item').forEach(item => {
item.addEventListener('click', () => {
dropdown.classList.remove('visible');
setTimeout(() => dropdown.remove(), 150);
const tracks = listenbrainzTracksCache[identifier];
if (!tracks || tracks.length === 0) {
showToast('No tracks cached. Try opening the playlist first.', 'error');
return;
}
if (item.dataset.action === 'download') {
wingItDownload(tracks, title, 'ListenBrainz', identifier, true);
} else {
_wingItSync(tracks, title, 'ListenBrainz', identifier);
}
});
});
const btnRect2 = btn.getBoundingClientRect();
if (btnRect2.top < 200) dropdown.classList.add('flip-down');
wrap.appendChild(dropdown);
requestAnimationFrame(() => dropdown.classList.add('visible'));
setTimeout(() => {
const closeHandler = e => {
if (!dropdown.contains(e.target) && e.target !== btn) {
dropdown.classList.remove('visible');
setTimeout(() => dropdown.remove(), 150);
document.removeEventListener('click', closeHandler);
}
};
document.addEventListener('click', closeHandler);
}, 50);
}
async function _wingItFromLBCard(identifier, title) {
// Legacy — kept for backward compat
const tracks = listenbrainzTracksCache[identifier];
if (!tracks || tracks.length === 0) {
showToast('No tracks cached for this playlist. Try opening the discovery modal first.', 'error');

View file

@ -5170,35 +5170,102 @@ body.helper-mode-active #dashboard-activity-feed:hover {
.notif-entry-link:hover { opacity: 0.7; }
/* ── Wing It Button ── */
/* ── Wing It Button + Dropdown ── */
.wing-it-wrap {
position: relative;
display: inline-block;
}
.wing-it-btn {
background: rgba(255, 183, 77, 0.08) !important;
border: 1px solid rgba(255, 183, 77, 0.3) !important;
color: #ffb74d !important;
font-size: 14px !important;
font-weight: 700 !important;
padding: 10px 20px !important;
background: rgba(255, 255, 255, 0.05) !important;
border: 1px solid rgba(255, 255, 255, 0.1) !important;
color: rgba(255, 255, 255, 0.6) !important;
font-size: 13px !important;
font-weight: 600 !important;
padding: 8px 16px !important;
border-radius: 10px !important;
cursor: pointer;
transition: all 0.2s;
}
.wing-it-btn:hover {
background: rgba(255, 183, 77, 0.15) !important;
border-color: rgba(255, 183, 77, 0.5) !important;
background: rgba(255, 255, 255, 0.1) !important;
border-color: rgba(255, 255, 255, 0.2) !important;
color: #fff !important;
}
.wing-it-dropdown {
position: absolute;
bottom: calc(100% + 6px);
left: 50%;
transform: translateX(-50%) translateY(4px) scale(0.96);
background: rgba(18, 18, 26, 0.97);
backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
padding: 4px;
min-width: 180px;
box-shadow: 0 12px 36px rgba(0, 0, 0, 0.5);
z-index: 10001;
opacity: 0;
transform: translateY(4px) scale(0.96);
transition: opacity 0.15s, transform 0.15s;
pointer-events: none;
}
.wing-it-dropdown.visible {
opacity: 1;
transform: translateX(-50%) translateY(0) scale(1);
pointer-events: auto;
}
.wing-it-dropdown.flip-down {
bottom: auto;
top: calc(100% + 6px);
}
.wing-it-dropdown-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
border-radius: 8px;
cursor: pointer;
transition: background 0.15s;
border: none;
background: none;
width: 100%;
text-align: left;
color: rgba(255, 255, 255, 0.8);
font-size: 13px;
font-weight: 500;
}
.wing-it-dropdown-item:hover {
background: rgba(255, 255, 255, 0.06);
}
.wing-it-dropdown-icon {
font-size: 15px;
width: 20px;
text-align: center;
flex-shrink: 0;
}
.wing-it-dropdown-label { flex: 1; }
.wing-it-dropdown-hint {
font-size: 10px;
color: rgba(255, 255, 255, 0.25);
}
.wing-it-btn-sm {
background: transparent;
border: 1px solid rgba(255, 183, 77, 0.25);
color: #ffb74d;
border: 1px solid rgba(255, 255, 255, 0.08);
color: rgba(255, 255, 255, 0.5);
font-size: 11px;
font-weight: 600;
border-radius: 6px;
padding: 4px 10px;
cursor: pointer;
transition: all 0.2s;
}
.wing-it-btn-sm:hover {
background: rgba(255, 183, 77, 0.1);
border-color: rgba(255, 183, 77, 0.5);
background: rgba(255, 255, 255, 0.08);
color: rgba(255, 255, 255, 0.8);
}
/* Desktop-Only Optimizations */