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:
parent
a9dd93b176
commit
0e3a217591
2 changed files with 285 additions and 28 deletions
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
Loading…
Reference in a new issue