Explorer improvements: discover from Explorer, status badges, auto-refresh
1. Discover button on undiscovered playlist cards — triggers discovery directly from Explorer instead of redirecting to Sync page. Button changes to "Open" to reopen modal after closing. 2. Status badges on playlist cards: checkmark (in library), heart (wishlisted), star (fully discovered), percentage (needs discovery). Meta line shows "N in library · M wishlisted" counts. 3. Auto-refresh: polls every 5s during active discovery to update cards. WebSocket listener for discovery:progress events. Cards refresh when discovery completes. 4. Explored tracking: playlists get green checkmark badge after tree is built (session-only, resets on reload). Backend: new get_mirrored_playlist_status_counts with fail-safe design — core discovery counts use simple reliable queries, library/wishlist counts are best-effort extras that won't break discovery detection. Card layout redesigned: badges inline with playlist name, discover button below meta text, no more absolute positioning overlaps.
This commit is contained in:
parent
93fb082172
commit
8b58434c17
4 changed files with 287 additions and 17 deletions
|
|
@ -9743,6 +9743,52 @@ class MusicDatabase:
|
||||||
logger.error(f"Error getting mirrored playlist discovery counts: {e}")
|
logger.error(f"Error getting mirrored playlist discovery counts: {e}")
|
||||||
return (0, 0)
|
return (0, 0)
|
||||||
|
|
||||||
|
def get_mirrored_playlist_status_counts(self, playlist_id: int) -> dict:
|
||||||
|
"""Return discovery, wishlisted, and downloaded counts for a mirrored playlist.
|
||||||
|
Discovery counts are critical (same as old method). Library/wishlist counts are
|
||||||
|
best-effort extras that won't break discovery detection if they fail."""
|
||||||
|
result = {'total': 0, 'discovered': 0, 'wishlisted': 0, 'in_library': 0}
|
||||||
|
try:
|
||||||
|
with self._get_connection() as conn:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# Core counts — same reliable queries as get_mirrored_playlist_discovery_counts
|
||||||
|
cursor.execute(
|
||||||
|
"SELECT COUNT(*) as total FROM mirrored_playlist_tracks WHERE playlist_id = ?",
|
||||||
|
(playlist_id,)
|
||||||
|
)
|
||||||
|
result['total'] = cursor.fetchone()['total']
|
||||||
|
cursor.execute(
|
||||||
|
"SELECT COUNT(*) as discovered FROM mirrored_playlist_tracks WHERE playlist_id = ? AND extra_data LIKE '%\"discovered\": true%'",
|
||||||
|
(playlist_id,)
|
||||||
|
)
|
||||||
|
result['discovered'] = cursor.fetchone()['discovered']
|
||||||
|
|
||||||
|
# Best-effort extras — won't break if tracks table has issues
|
||||||
|
try:
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT
|
||||||
|
SUM(CASE WHEN mpt.source_track_id IS NOT NULL AND mpt.source_track_id != ''
|
||||||
|
AND EXISTS (SELECT 1 FROM wishlist_tracks wt
|
||||||
|
WHERE wt.spotify_track_id = mpt.source_track_id)
|
||||||
|
THEN 1 ELSE 0 END) as wishlisted,
|
||||||
|
SUM(CASE WHEN EXISTS (SELECT 1 FROM tracks t
|
||||||
|
WHERE t.title = mpt.track_name COLLATE NOCASE
|
||||||
|
AND t.artist = mpt.artist_name COLLATE NOCASE)
|
||||||
|
THEN 1 ELSE 0 END) as in_library
|
||||||
|
FROM mirrored_playlist_tracks mpt
|
||||||
|
WHERE mpt.playlist_id = ?
|
||||||
|
""", (playlist_id,))
|
||||||
|
row = cursor.fetchone()
|
||||||
|
result['wishlisted'] = row['wishlisted'] or 0
|
||||||
|
result['in_library'] = row['in_library'] or 0
|
||||||
|
except Exception as extra_err:
|
||||||
|
logger.debug(f"Optional status counts failed for playlist {playlist_id}: {extra_err}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting mirrored playlist status counts: {e}")
|
||||||
|
return result
|
||||||
|
|
||||||
def delete_mirrored_playlist(self, playlist_id: int) -> bool:
|
def delete_mirrored_playlist(self, playlist_id: int) -> bool:
|
||||||
"""Delete a mirrored playlist and its tracks (CASCADE)."""
|
"""Delete a mirrored playlist and its tracks (CASCADE)."""
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -43938,9 +43938,11 @@ def get_mirrored_playlists_endpoint():
|
||||||
profile_id = get_current_profile_id()
|
profile_id = get_current_profile_id()
|
||||||
playlists = database.get_mirrored_playlists(profile_id=profile_id)
|
playlists = database.get_mirrored_playlists(profile_id=profile_id)
|
||||||
for pl in playlists:
|
for pl in playlists:
|
||||||
discovered, total = database.get_mirrored_playlist_discovery_counts(pl['id'])
|
counts = database.get_mirrored_playlist_status_counts(pl['id'])
|
||||||
pl['discovered_count'] = discovered
|
pl['discovered_count'] = counts['discovered']
|
||||||
pl['total_count'] = total
|
pl['total_count'] = counts['total']
|
||||||
|
pl['wishlisted_count'] = counts['wishlisted']
|
||||||
|
pl['in_library_count'] = counts['in_library']
|
||||||
return jsonify(playlists)
|
return jsonify(playlists)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error getting mirrored playlists: {e}")
|
logger.error(f"Error getting mirrored playlists: {e}")
|
||||||
|
|
|
||||||
|
|
@ -66982,7 +66982,35 @@ function initExplorer() {
|
||||||
if (_explorer.initialized) return;
|
if (_explorer.initialized) return;
|
||||||
_explorer.initialized = true;
|
_explorer.initialized = true;
|
||||||
_explorer._playlists = [];
|
_explorer._playlists = [];
|
||||||
|
_explorer._activeSource = null;
|
||||||
|
|
||||||
|
_explorerLoadPlaylists();
|
||||||
|
|
||||||
|
// Listen for discovery completion to auto-refresh playlist cards
|
||||||
|
if (typeof socket !== 'undefined') {
|
||||||
|
socket.on('discovery:progress', (data) => {
|
||||||
|
if (!document.getElementById('playlist-explorer-page')?.classList.contains('active')) return;
|
||||||
|
// Match mirrored playlist discovery events
|
||||||
|
if (data.phase === 'discovered' || data.phase === 'sync_complete' || data.complete) {
|
||||||
|
// Discovery finished — refresh playlists after brief delay for DB commit
|
||||||
|
setTimeout(() => _explorerLoadPlaylists(), 1500);
|
||||||
|
}
|
||||||
|
// Live progress update on cards during discovery
|
||||||
|
if (data.id && data.id.startsWith('mirrored_')) {
|
||||||
|
const plId = parseInt(data.id.replace('mirrored_', ''));
|
||||||
|
const card = document.querySelector(`.explorer-picker-card[data-id="${plId}"]`);
|
||||||
|
if (card) {
|
||||||
|
const meta = card.querySelector('.explorer-picker-card-meta');
|
||||||
|
if (meta && data.progress != null) {
|
||||||
|
meta.innerHTML = `<span class="explorer-discovering-live">Discovering... ${Math.round(data.progress)}%</span>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _explorerLoadPlaylists() {
|
||||||
fetch('/api/mirrored-playlists')
|
fetch('/api/mirrored-playlists')
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
|
|
@ -67009,24 +67037,27 @@ function initExplorer() {
|
||||||
const sourceNames = { spotify: 'Spotify', tidal: 'Tidal', deezer: 'Deezer', youtube: 'YouTube', beatport: 'Beatport', file: 'File', other: 'Other' };
|
const sourceNames = { spotify: 'Spotify', tidal: 'Tidal', deezer: 'Deezer', youtube: 'YouTube', beatport: 'Beatport', file: 'File', other: 'Other' };
|
||||||
const sources = Object.keys(groups);
|
const sources = Object.keys(groups);
|
||||||
if (sources.length <= 1) {
|
if (sources.length <= 1) {
|
||||||
// Only one source — no tabs needed
|
|
||||||
tabsEl.style.display = 'none';
|
tabsEl.style.display = 'none';
|
||||||
} else {
|
} else {
|
||||||
tabsEl.innerHTML = sources.map((src, i) => {
|
tabsEl.innerHTML = sources.map((src, i) => {
|
||||||
const label = sourceNames[src] || src.charAt(0).toUpperCase() + src.slice(1);
|
const label = sourceNames[src] || src.charAt(0).toUpperCase() + src.slice(1);
|
||||||
const count = groups[src].length;
|
const count = groups[src].length;
|
||||||
return `<button class="explorer-picker-tab ${i === 0 ? 'active' : ''}" data-source="${src}" onclick="explorerSwitchPickerTab('${src}')">${label} <span class="explorer-picker-tab-count">${count}</span></button>`;
|
const isActive = _explorer._activeSource === src || (!_explorer._activeSource && i === 0);
|
||||||
|
return `<button class="explorer-picker-tab ${isActive ? 'active' : ''}" data-source="${src}" onclick="explorerSwitchPickerTab('${src}')">${label} <span class="explorer-picker-tab-count">${count}</span></button>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show first source
|
// Show active or first source
|
||||||
explorerRenderPickerCards(sources[0]);
|
const activeSource = _explorer._activeSource || sources[0];
|
||||||
|
_explorer._activeSource = activeSource;
|
||||||
|
explorerRenderPickerCards(activeSource);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
function explorerSwitchPickerTab(source) {
|
function explorerSwitchPickerTab(source) {
|
||||||
|
_explorer._activeSource = source;
|
||||||
document.querySelectorAll('.explorer-picker-tab').forEach(t => t.classList.toggle('active', t.dataset.source === source));
|
document.querySelectorAll('.explorer-picker-tab').forEach(t => t.classList.toggle('active', t.dataset.source === source));
|
||||||
explorerRenderPickerCards(source);
|
explorerRenderPickerCards(source);
|
||||||
}
|
}
|
||||||
|
|
@ -67043,18 +67074,60 @@ function explorerRenderPickerCards(source) {
|
||||||
const pct = total > 0 ? Math.round((discovered / total) * 100) : 0;
|
const pct = total > 0 ? Math.round((discovered / total) * 100) : 0;
|
||||||
const isReady = pct >= 50;
|
const isReady = pct >= 50;
|
||||||
const isActive = _explorer.playlistId === p.id;
|
const isActive = _explorer.playlistId === p.id;
|
||||||
|
const isFullyDiscovered = pct === 100;
|
||||||
|
const wasExplored = p.explored || false;
|
||||||
|
const wishlisted = p.wishlisted_count || 0;
|
||||||
|
const inLibrary = p.in_library_count || 0;
|
||||||
|
|
||||||
|
// Status badge: checkmark if explored/in-library, star if ready, % if needs discovery
|
||||||
|
let statusBadge = '';
|
||||||
|
if (inLibrary > 0 && inLibrary >= total * 0.8) {
|
||||||
|
statusBadge = '<div class="explorer-picker-card-badge downloaded" title="Most tracks in library">✓</div>';
|
||||||
|
} else if (wasExplored) {
|
||||||
|
statusBadge = '<div class="explorer-picker-card-badge explored" title="Already explored">✓</div>';
|
||||||
|
} else if (wishlisted > 0) {
|
||||||
|
statusBadge = '<div class="explorer-picker-card-badge wishlisted" title="Tracks wishlisted">♥</div>';
|
||||||
|
} else if (isFullyDiscovered) {
|
||||||
|
statusBadge = '<div class="explorer-picker-card-badge ready" title="Ready to explore">★</div>';
|
||||||
|
} else if (!isReady) {
|
||||||
|
statusBadge = `<div class="explorer-picker-card-badge needs-discovery" title="Needs discovery (${pct}%)">${pct}%</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Meta line with status indicators
|
||||||
|
let metaHTML;
|
||||||
|
const statusParts = [];
|
||||||
|
if (inLibrary > 0) statusParts.push(`<span class="explorer-picker-in-library">${inLibrary} in library</span>`);
|
||||||
|
if (wishlisted > 0) statusParts.push(`<span class="explorer-picker-wishlisted">${wishlisted} wishlisted</span>`);
|
||||||
|
|
||||||
|
if (isFullyDiscovered) {
|
||||||
|
metaHTML = `${total} tracks · <span class="explorer-picker-discovered">Fully discovered</span>`;
|
||||||
|
} else if (isReady) {
|
||||||
|
metaHTML = `${total} tracks · ${pct}% discovered`;
|
||||||
|
} else {
|
||||||
|
metaHTML = `${total} tracks · <span class="explorer-picker-not-ready">${pct}% discovered</span>`;
|
||||||
|
}
|
||||||
|
if (statusParts.length > 0) {
|
||||||
|
metaHTML += `<br>${statusParts.join(' · ')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Discover button for undiscovered playlists (replaces redirect to Sync)
|
||||||
|
const discoverBtn = !isReady ? `<button class="explorer-picker-discover-btn" onclick="event.stopPropagation(); explorerStartDiscovery(${p.id})" title="Start discovery">Discover</button>` : '';
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="explorer-picker-card ${isActive ? 'active' : ''} ${!isReady ? 'not-ready' : ''}"
|
<div class="explorer-picker-card ${isActive ? 'active' : ''} ${!isReady ? 'not-ready' : ''} ${wasExplored ? 'explored' : ''}"
|
||||||
data-id="${p.id}"
|
data-id="${p.id}"
|
||||||
onclick="${isReady ? `explorerSelectPlaylist(${p.id}, this)` : `explorerRedirectToDiscover(${p.id})`}">
|
onclick="${isReady ? `explorerSelectPlaylist(${p.id}, this)` : ''}">
|
||||||
<div class="explorer-picker-card-art">
|
<div class="explorer-picker-card-art">
|
||||||
${img ? `<img src="${img}" alt="" loading="lazy">` : '<div class="explorer-picker-card-art-placeholder">♫</div>'}
|
${img ? `<img src="${img}" alt="" loading="lazy">` : '<div class="explorer-picker-card-art-placeholder">♫</div>'}
|
||||||
</div>
|
</div>
|
||||||
<div class="explorer-picker-card-info">
|
<div class="explorer-picker-card-info">
|
||||||
<div class="explorer-picker-card-name">${p.name || 'Untitled'}</div>
|
<div class="explorer-picker-card-name-row">
|
||||||
<div class="explorer-picker-card-meta">${total} tracks · ${isReady ? `${pct}% discovered` : `<span class="explorer-picker-not-ready">${pct}% — needs discovery</span>`}</div>
|
<div class="explorer-picker-card-name">${p.name || 'Untitled'}</div>
|
||||||
|
${statusBadge}
|
||||||
|
</div>
|
||||||
|
<div class="explorer-picker-card-meta">${metaHTML}</div>
|
||||||
|
${discoverBtn ? `<div class="explorer-picker-card-actions">${discoverBtn}</div>` : ''}
|
||||||
</div>
|
</div>
|
||||||
${isReady ? '' : '<div class="explorer-picker-card-lock" title="Less than 50% discovered — click to go discover">🔒</div>'}
|
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
@ -67069,13 +67142,59 @@ function explorerSelectPlaylist(id, el) {
|
||||||
function explorerRedirectToDiscover(playlistId) {
|
function explorerRedirectToDiscover(playlistId) {
|
||||||
showToast('This playlist needs more tracks discovered before exploring. Redirecting to Sync...', 'info');
|
showToast('This playlist needs more tracks discovered before exploring. Redirecting to Sync...', 'info');
|
||||||
navigateToPage('sync');
|
navigateToPage('sync');
|
||||||
// Switch to mirrored tab after page loads
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const mirroredBtn = document.querySelector('.sync-tab-button[data-tab="mirrored"]');
|
const mirroredBtn = document.querySelector('.sync-tab-button[data-tab="mirrored"]');
|
||||||
if (mirroredBtn) mirroredBtn.click();
|
if (mirroredBtn) mirroredBtn.click();
|
||||||
}, 200);
|
}, 200);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function explorerStartDiscovery(playlistId) {
|
||||||
|
const card = document.querySelector(`.explorer-picker-card[data-id="${playlistId}"]`);
|
||||||
|
const btn = card?.querySelector('.explorer-picker-discover-btn');
|
||||||
|
if (btn) { btn.disabled = true; btn.textContent = 'Starting...'; }
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (typeof discoverMirroredPlaylist === 'function') {
|
||||||
|
await discoverMirroredPlaylist(playlistId);
|
||||||
|
if (btn) { btn.disabled = false; btn.textContent = 'Open'; btn.title = 'Reopen discovery modal'; }
|
||||||
|
|
||||||
|
// Poll for card updates while discovery is in progress
|
||||||
|
_explorerStartDiscoveryPoller(playlistId);
|
||||||
|
} else {
|
||||||
|
explorerRedirectToDiscover(playlistId);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
showToast(`Discovery failed: ${err.message}`, 'error');
|
||||||
|
if (btn) { btn.disabled = false; btn.textContent = 'Discover'; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _explorerStartDiscoveryPoller(playlistId) {
|
||||||
|
// Poll every 5s to refresh playlist cards until this playlist is ready
|
||||||
|
if (_explorer._discoveryPoller) clearInterval(_explorer._discoveryPoller);
|
||||||
|
_explorer._discoveryPoller = setInterval(async () => {
|
||||||
|
// Stop polling if Explorer page isn't active
|
||||||
|
if (!document.getElementById('playlist-explorer-page')?.classList.contains('active')) {
|
||||||
|
clearInterval(_explorer._discoveryPoller);
|
||||||
|
_explorer._discoveryPoller = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Check if the mirrored playlist state shows discovery is done
|
||||||
|
const tempHash = `mirrored_${playlistId}`;
|
||||||
|
const state = youtubePlaylistStates[tempHash];
|
||||||
|
const isDone = state && (state.phase === 'discovered' || state.phase === 'sync_complete');
|
||||||
|
|
||||||
|
// Refresh cards from API
|
||||||
|
await _explorerLoadPlaylists();
|
||||||
|
|
||||||
|
// Stop polling once discovery is complete
|
||||||
|
if (isDone) {
|
||||||
|
clearInterval(_explorer._discoveryPoller);
|
||||||
|
_explorer._discoveryPoller = null;
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
function explorerSetMode(mode) {
|
function explorerSetMode(mode) {
|
||||||
_explorer.mode = mode;
|
_explorer.mode = mode;
|
||||||
document.querySelectorAll('.explorer-mode-btn').forEach(btn => {
|
document.querySelectorAll('.explorer-mode-btn').forEach(btn => {
|
||||||
|
|
@ -67175,6 +67294,31 @@ async function explorerBuildTree() {
|
||||||
if (progress) progress.style.display = 'none';
|
if (progress) progress.style.display = 'none';
|
||||||
_explorerUpdateCount();
|
_explorerUpdateCount();
|
||||||
|
|
||||||
|
// Mark playlist as explored (persists in session for badge display)
|
||||||
|
const exploredPl = _explorer._playlists.find(p => p.id === playlistId);
|
||||||
|
if (exploredPl) {
|
||||||
|
exploredPl.explored = true;
|
||||||
|
// Update card badge without full re-render
|
||||||
|
const card = document.querySelector(`.explorer-picker-card[data-id="${playlistId}"]`);
|
||||||
|
if (card) {
|
||||||
|
card.classList.add('explored');
|
||||||
|
const oldBadge = card.querySelector('.explorer-picker-card-badge');
|
||||||
|
const badgeHTML = '<div class="explorer-picker-card-badge explored" title="Already explored">✓</div>';
|
||||||
|
if (oldBadge) {
|
||||||
|
oldBadge.outerHTML = badgeHTML;
|
||||||
|
} else {
|
||||||
|
// Insert badge into the name row
|
||||||
|
const nameRow = card.querySelector('.explorer-picker-card-name-row');
|
||||||
|
if (nameRow) {
|
||||||
|
nameRow.insertAdjacentHTML('beforeend', badgeHTML);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Remove discover button if present (no longer needed)
|
||||||
|
const discoverBtn = card.querySelector('.explorer-picker-card-actions');
|
||||||
|
if (discoverBtn) discoverBtn.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Draw all connections now that the tree is stable
|
// Draw all connections now that the tree is stable
|
||||||
setTimeout(() => _explorerRedrawAllConnections(true), 100);
|
setTimeout(() => _explorerRedrawAllConnections(true), 100);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -50569,11 +50569,11 @@ tr.tag-diff-same {
|
||||||
|
|
||||||
.explorer-picker-card {
|
.explorer-picker-card {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: flex-start;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
padding: 8px 14px 8px 8px;
|
padding: 10px 14px 10px 10px;
|
||||||
min-width: 200px;
|
min-width: 220px;
|
||||||
max-width: 260px;
|
max-width: 280px;
|
||||||
background: rgba(255, 255, 255, 0.03);
|
background: rgba(255, 255, 255, 0.03);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
|
|
@ -50625,6 +50625,12 @@ tr.tag-diff-same {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.explorer-picker-card-name-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
.explorer-picker-card-name {
|
.explorer-picker-card-name {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
|
@ -50638,6 +50644,11 @@ tr.tag-diff-same {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: rgba(255, 255, 255, 0.35);
|
color: rgba(255, 255, 255, 0.35);
|
||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.explorer-picker-card-actions {
|
||||||
|
margin-top: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.explorer-picker-card.not-ready {
|
.explorer-picker-card.not-ready {
|
||||||
|
|
@ -50654,6 +50665,25 @@ tr.tag-diff-same {
|
||||||
color: rgba(245, 166, 35, 0.8);
|
color: rgba(245, 166, 35, 0.8);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.explorer-picker-discovered {
|
||||||
|
color: rgba(74, 222, 128, 0.8);
|
||||||
|
}
|
||||||
|
.explorer-picker-in-library {
|
||||||
|
color: rgba(96, 165, 250, 0.7); font-size: 10px;
|
||||||
|
}
|
||||||
|
.explorer-picker-wishlisted {
|
||||||
|
color: rgba(244, 114, 182, 0.7); font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.explorer-discovering-live {
|
||||||
|
color: rgba(96, 165, 250, 0.9);
|
||||||
|
animation: explorer-pulse 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes explorer-pulse {
|
||||||
|
0%, 100% { opacity: 0.7; }
|
||||||
|
50% { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
.explorer-picker-card-lock {
|
.explorer-picker-card-lock {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 6px;
|
top: 6px;
|
||||||
|
|
@ -50666,6 +50696,54 @@ tr.tag-diff-same {
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Status badges on picker cards — inline, not absolute */
|
||||||
|
.explorer-picker-card-badge {
|
||||||
|
font-size: 9px; font-weight: 800; padding: 2px 6px;
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
border-radius: 5px; flex-shrink: 0; margin-left: auto; line-height: 1;
|
||||||
|
}
|
||||||
|
.explorer-picker-card-badge.explored {
|
||||||
|
background: rgba(74, 222, 128, 0.15); color: #4ade80;
|
||||||
|
border: 1px solid rgba(74, 222, 128, 0.3);
|
||||||
|
}
|
||||||
|
.explorer-picker-card-badge.ready {
|
||||||
|
background: rgba(251, 191, 36, 0.12); color: #fbbf24;
|
||||||
|
border: 1px solid rgba(251, 191, 36, 0.25); font-size: 10px;
|
||||||
|
}
|
||||||
|
.explorer-picker-card-badge.needs-discovery {
|
||||||
|
background: rgba(245, 166, 35, 0.1); color: rgba(245, 166, 35, 0.7);
|
||||||
|
border: 1px solid rgba(245, 166, 35, 0.2);
|
||||||
|
}
|
||||||
|
.explorer-picker-card-badge.wishlisted {
|
||||||
|
background: rgba(244, 114, 182, 0.15); color: #f472b6;
|
||||||
|
border: 1px solid rgba(244, 114, 182, 0.3);
|
||||||
|
}
|
||||||
|
.explorer-picker-card-badge.downloaded {
|
||||||
|
background: rgba(96, 165, 250, 0.15); color: #60a5fa;
|
||||||
|
border: 1px solid rgba(96, 165, 250, 0.3);
|
||||||
|
}
|
||||||
|
.explorer-picker-card.explored {
|
||||||
|
border-color: rgba(74, 222, 128, 0.15);
|
||||||
|
}
|
||||||
|
.explorer-picker-card.explored:hover {
|
||||||
|
border-color: rgba(74, 222, 128, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Discover button — inline in card info area */
|
||||||
|
.explorer-picker-discover-btn {
|
||||||
|
padding: 3px 8px; border-radius: 5px; font-size: 9px; font-weight: 700;
|
||||||
|
background: rgba(96, 165, 250, 0.12); color: #60a5fa;
|
||||||
|
border: 1px solid rgba(96, 165, 250, 0.2); cursor: pointer;
|
||||||
|
transition: all 0.2s; text-transform: uppercase; letter-spacing: 0.5px;
|
||||||
|
margin-left: auto; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.explorer-picker-discover-btn:hover {
|
||||||
|
background: rgba(96, 165, 250, 0.25);
|
||||||
|
}
|
||||||
|
.explorer-picker-discover-btn:disabled {
|
||||||
|
opacity: 0.5; cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
.explorer-picker-empty {
|
.explorer-picker-empty {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: rgba(255, 255, 255, 0.3);
|
color: rgba(255, 255, 255, 0.3);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue