Add Discovery Pool dashboard tool card and revamp modal with premium category-card design
This commit is contained in:
parent
c9d83d5d13
commit
7647ac22ed
5 changed files with 1135 additions and 0 deletions
|
|
@ -6080,6 +6080,106 @@ class MusicDatabase:
|
|||
logger.error(f"Error invalidating sync match cache: {e}")
|
||||
return 0
|
||||
|
||||
# ==================== Discovery Pool Methods ====================
|
||||
|
||||
def get_discovery_pool_matched(self, limit: int = 500) -> list:
|
||||
"""Get all cached discovery matches, ordered by most recently used."""
|
||||
try:
|
||||
conn = self._get_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("""
|
||||
SELECT id, original_title, original_artist, normalized_title, normalized_artist,
|
||||
provider, match_confidence, matched_data_json, use_count, last_used_at, created_at
|
||||
FROM discovery_match_cache
|
||||
ORDER BY last_used_at DESC
|
||||
LIMIT ?
|
||||
""", (limit,))
|
||||
results = []
|
||||
for row in cursor.fetchall():
|
||||
try:
|
||||
matched_data = json.loads(row['matched_data_json'])
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
matched_data = {}
|
||||
results.append({
|
||||
'id': row['id'],
|
||||
'original_title': row['original_title'] or row['normalized_title'],
|
||||
'original_artist': row['original_artist'] or row['normalized_artist'],
|
||||
'provider': row['provider'],
|
||||
'confidence': row['match_confidence'],
|
||||
'matched_data': matched_data,
|
||||
'use_count': row['use_count'],
|
||||
'last_used_at': row['last_used_at'],
|
||||
'created_at': row['created_at'],
|
||||
})
|
||||
return results
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting discovery pool matched: {e}")
|
||||
return []
|
||||
|
||||
def get_discovery_pool_failed(self, profile_id: int = None, playlist_id: int = None) -> list:
|
||||
"""Get all tracks where discovery was attempted but failed."""
|
||||
try:
|
||||
conn = self._get_connection()
|
||||
cursor = conn.cursor()
|
||||
query = """
|
||||
SELECT mpt.id, mpt.track_name, mpt.artist_name, mpt.album_name,
|
||||
mpt.playlist_id, mp.name as playlist_name
|
||||
FROM mirrored_playlist_tracks mpt
|
||||
JOIN mirrored_playlists mp ON mpt.playlist_id = mp.id
|
||||
WHERE mpt.extra_data LIKE '%"discovery_attempted": true%'
|
||||
AND mpt.extra_data NOT LIKE '%"discovered": true%'
|
||||
"""
|
||||
params = []
|
||||
if playlist_id:
|
||||
query += " AND mpt.playlist_id = ?"
|
||||
params.append(playlist_id)
|
||||
elif profile_id:
|
||||
query += " AND mp.profile_id = ?"
|
||||
params.append(profile_id)
|
||||
query += " ORDER BY mp.name, mpt.track_name"
|
||||
cursor.execute(query, params)
|
||||
return [dict(row) for row in cursor.fetchall()]
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting discovery pool failed: {e}")
|
||||
return []
|
||||
|
||||
def delete_discovery_cache_entry(self, entry_id: int) -> bool:
|
||||
"""Delete a single entry from the discovery match cache."""
|
||||
try:
|
||||
conn = self._get_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("DELETE FROM discovery_match_cache WHERE id = ?", (entry_id,))
|
||||
conn.commit()
|
||||
return cursor.rowcount > 0
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting discovery cache entry: {e}")
|
||||
return False
|
||||
|
||||
def get_discovery_pool_stats(self, profile_id: int = None) -> dict:
|
||||
"""Get counts for matched and failed discovery tracks."""
|
||||
try:
|
||||
conn = self._get_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT COUNT(*) as cnt FROM discovery_match_cache")
|
||||
matched = cursor.fetchone()['cnt']
|
||||
|
||||
query = """
|
||||
SELECT COUNT(*) as cnt FROM mirrored_playlist_tracks mpt
|
||||
JOIN mirrored_playlists mp ON mpt.playlist_id = mp.id
|
||||
WHERE mpt.extra_data LIKE '%"discovery_attempted": true%'
|
||||
AND mpt.extra_data NOT LIKE '%"discovered": true%'
|
||||
"""
|
||||
params = []
|
||||
if profile_id:
|
||||
query += " AND mp.profile_id = ?"
|
||||
params.append(profile_id)
|
||||
cursor.execute(query, params)
|
||||
failed = cursor.fetchone()['cnt']
|
||||
return {'matched': matched, 'failed': failed}
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting discovery pool stats: {e}")
|
||||
return {'matched': 0, 'failed': 0}
|
||||
|
||||
# ==================== Retag Tool Methods ====================
|
||||
|
||||
def add_retag_group(self, group_type: str, artist_name: str, album_name: str,
|
||||
|
|
|
|||
102
web_server.py
102
web_server.py
|
|
@ -29138,6 +29138,108 @@ def clear_mirrored_discovery_endpoint(playlist_id):
|
|||
logger.error(f"Error clearing mirrored discovery: {e}")
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
# ==================== Discovery Pool ====================
|
||||
|
||||
@app.route('/api/discovery-pool', methods=['GET'])
|
||||
def get_discovery_pool():
|
||||
"""List matched and failed discovery tracks, optionally filtered by playlist."""
|
||||
try:
|
||||
database = get_database()
|
||||
profile_id = get_current_profile_id()
|
||||
playlist_id = request.args.get('playlist_id', type=int)
|
||||
|
||||
matched = database.get_discovery_pool_matched()
|
||||
failed = database.get_discovery_pool_failed(profile_id=profile_id, playlist_id=playlist_id)
|
||||
stats = database.get_discovery_pool_stats(profile_id=profile_id)
|
||||
|
||||
# Playlist list for the filter dropdown
|
||||
playlists = database.get_mirrored_playlists(profile_id=profile_id)
|
||||
playlist_options = [{'id': p['id'], 'name': p['name']} for p in playlists]
|
||||
|
||||
return jsonify({
|
||||
'matched': matched,
|
||||
'failed': failed,
|
||||
'stats': stats,
|
||||
'playlists': playlist_options,
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting discovery pool: {e}")
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
@app.route('/api/discovery-pool/fix', methods=['POST'])
|
||||
def fix_discovery_pool_track():
|
||||
"""Manually fix a failed discovery by linking a mirrored track to a Spotify/iTunes result."""
|
||||
try:
|
||||
data = request.get_json()
|
||||
track_id = data.get('track_id')
|
||||
spotify_track = data.get('spotify_track')
|
||||
if not track_id or not spotify_track:
|
||||
return jsonify({"error": "track_id and spotify_track required"}), 400
|
||||
|
||||
database = get_database()
|
||||
|
||||
# Build matched_data in the same format as the discovery flow
|
||||
artists = spotify_track.get('artists', [])
|
||||
album_raw = spotify_track.get('album', '')
|
||||
album_obj = album_raw if isinstance(album_raw, dict) else {'name': album_raw or ''}
|
||||
image_url = spotify_track.get('image_url', '')
|
||||
if not image_url and isinstance(album_raw, dict):
|
||||
images = album_raw.get('images', [])
|
||||
image_url = images[0].get('url', '') if images else ''
|
||||
|
||||
matched_data = {
|
||||
'id': spotify_track.get('id', ''),
|
||||
'name': spotify_track.get('name', ''),
|
||||
'artists': [{'name': a} if isinstance(a, str) else a for a in artists],
|
||||
'album': album_obj,
|
||||
'duration_ms': spotify_track.get('duration_ms', 0),
|
||||
'image_url': image_url,
|
||||
'source': 'spotify',
|
||||
}
|
||||
|
||||
# Update the mirrored track's extra_data
|
||||
extra_data = {
|
||||
'discovered': True,
|
||||
'provider': 'spotify',
|
||||
'confidence': 1.0,
|
||||
'matched_data': matched_data,
|
||||
'manual_match': True,
|
||||
}
|
||||
database.update_mirrored_track_extra_data(track_id, extra_data)
|
||||
|
||||
# Also save to discovery cache so future discoveries hit the cache
|
||||
# Need to get the track's original name/artist for the cache key
|
||||
try:
|
||||
conn = database._get_connection()
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT track_name, artist_name FROM mirrored_playlist_tracks WHERE id = ?", (track_id,))
|
||||
row = cursor.fetchone()
|
||||
if row:
|
||||
cache_key = _get_discovery_cache_key(row['track_name'], row['artist_name'])
|
||||
database.save_discovery_cache_match(
|
||||
cache_key[0], cache_key[1], 'spotify', 1.0, matched_data,
|
||||
row['track_name'], row['artist_name']
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return jsonify({"success": True})
|
||||
except Exception as e:
|
||||
logger.error(f"Error fixing discovery pool track: {e}")
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
@app.route('/api/discovery-pool/cache/<int:entry_id>', methods=['DELETE'])
|
||||
def delete_discovery_pool_cache_entry(entry_id):
|
||||
"""Remove a single entry from the discovery match cache."""
|
||||
try:
|
||||
database = get_database()
|
||||
if database.delete_discovery_cache_entry(entry_id):
|
||||
return jsonify({"success": True})
|
||||
return jsonify({"error": "Entry not found"}), 404
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting discovery cache entry: {e}")
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
@app.route('/api/mirrored-playlists/<int:playlist_id>/prepare-discovery', methods=['POST'])
|
||||
def prepare_mirrored_discovery(playlist_id):
|
||||
"""Register a mirrored playlist into youtube_playlist_states so the YouTube discovery pipeline can run."""
|
||||
|
|
|
|||
|
|
@ -657,6 +657,26 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tool-card" id="discovery-pool-card">
|
||||
<div class="tool-card-header">
|
||||
<h4 class="tool-card-title">Discovery Pool</h4>
|
||||
</div>
|
||||
<p class="tool-card-info">View and fix matched/failed discovery results across all mirrored playlists</p>
|
||||
<div class="tool-card-stats" id="discovery-pool-stats">
|
||||
<div class="stat-item">
|
||||
<span class="stat-item-label">Matched:</span>
|
||||
<span class="stat-item-value" id="discovery-pool-matched-count">—</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="stat-item-label">Failed:</span>
|
||||
<span class="stat-item-value" id="discovery-pool-failed-count" style="background-color: rgba(239, 68, 68, 0.15); color: #ef4444;">—</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tool-card-controls">
|
||||
<button onclick="openDiscoveryPoolModal()">Open Discovery Pool</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tool-card" id="retag-tool-card">
|
||||
<div class="tool-card-header">
|
||||
<h4 class="tool-card-title">Retag Tool</h4>
|
||||
|
|
@ -1505,6 +1525,7 @@
|
|||
<div class="sync-tab-content" id="mirrored-tab-content">
|
||||
<div class="playlist-header">
|
||||
<h3>Mirrored Playlists</h3>
|
||||
<button class="pool-trigger-btn" onclick="openDiscoveryPoolModal()" title="View matched and failed discovery tracks">Discovery Pool</button>
|
||||
<button class="refresh-button mirrored" id="mirrored-refresh-btn">Refresh</button>
|
||||
</div>
|
||||
<div class="playlist-scroll-container" id="mirrored-playlist-container">
|
||||
|
|
|
|||
|
|
@ -16335,6 +16335,9 @@ async function loadDashboardData() {
|
|||
stopDbStatsPolling(); // Ensure no duplicates
|
||||
dbStatsInterval = setInterval(fetchAndUpdateDbStats, 30000);
|
||||
|
||||
// Initial load of discovery pool stats for the tool card
|
||||
loadDiscoveryPoolStats();
|
||||
|
||||
// Initial load of wishlist count
|
||||
await updateWishlistCount();
|
||||
|
||||
|
|
@ -42065,6 +42068,433 @@ async function clearMirroredDiscovery(playlistId, name) {
|
|||
}
|
||||
}
|
||||
|
||||
// ==================== Discovery Pool Modal ====================
|
||||
|
||||
let _discoveryPoolOverlay = null;
|
||||
let _discoveryPoolData = null;
|
||||
let _discoveryPoolView = 'categories'; // 'categories' | 'failed' | 'matched'
|
||||
let _discoveryPoolPlaylistFilter = null;
|
||||
|
||||
async function loadDiscoveryPoolStats() {
|
||||
try {
|
||||
const res = await fetch('/api/discovery-pool');
|
||||
const data = await res.json();
|
||||
const matchedEl = document.getElementById('discovery-pool-matched-count');
|
||||
const failedEl = document.getElementById('discovery-pool-failed-count');
|
||||
if (matchedEl) matchedEl.textContent = data.stats.matched || 0;
|
||||
if (failedEl) failedEl.textContent = data.stats.failed || 0;
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
async function openDiscoveryPoolModal(playlistId = null) {
|
||||
_discoveryPoolPlaylistFilter = playlistId;
|
||||
_discoveryPoolView = 'categories';
|
||||
|
||||
// Fetch pool data
|
||||
let url = '/api/discovery-pool';
|
||||
if (playlistId) url += `?playlist_id=${playlistId}`;
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
_discoveryPoolData = await res.json();
|
||||
} catch (err) {
|
||||
showToast('Failed to load discovery pool', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove existing overlay if present
|
||||
if (_discoveryPoolOverlay) _discoveryPoolOverlay.remove();
|
||||
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'modal-overlay';
|
||||
overlay.id = 'discovery-pool-overlay';
|
||||
overlay.onclick = (e) => { if (e.target === overlay) closeDiscoveryPoolModal(); };
|
||||
|
||||
const playlistOptions = (_discoveryPoolData.playlists || [])
|
||||
.map(p => `<option value="${p.id}" ${playlistId == p.id ? 'selected' : ''}>${_esc(p.name)}</option>`)
|
||||
.join('');
|
||||
|
||||
const failedCount = _discoveryPoolData.stats.failed || 0;
|
||||
const matchedCount = _discoveryPoolData.stats.matched || 0;
|
||||
|
||||
overlay.innerHTML = `
|
||||
<div class="modal-container playlist-modal">
|
||||
<div class="playlist-modal-header">
|
||||
<div class="playlist-header-content">
|
||||
<h2>Discovery Pool</h2>
|
||||
<div class="playlist-quick-info">
|
||||
<span class="playlist-track-count" id="pool-header-matched">${matchedCount} Matched</span>
|
||||
<span class="playlist-owner ${failedCount > 0 ? 'pool-header-failed-highlight' : ''}" id="pool-header-failed">${failedCount} Failed</span>
|
||||
<select class="pool-playlist-filter" onchange="filterDiscoveryPool(this.value)">
|
||||
<option value="">All Playlists</option>
|
||||
${playlistOptions}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<span class="playlist-modal-close" onclick="closeDiscoveryPoolModal()">×</span>
|
||||
</div>
|
||||
|
||||
<div class="playlist-modal-body">
|
||||
<div class="pool-category-grid" id="pool-category-grid">
|
||||
<div class="pool-category-card failed" onclick="showPoolList('failed')">
|
||||
<div class="pool-category-fallback failed"></div>
|
||||
<div class="pool-category-overlay"></div>
|
||||
<div class="pool-category-content">
|
||||
<div class="pool-category-icon">⚠</div>
|
||||
<div class="pool-category-count failed" id="pool-cat-failed-count">${failedCount}</div>
|
||||
<div class="pool-category-label">tracks need attention</div>
|
||||
</div>
|
||||
<div class="pool-category-top-bar failed"></div>
|
||||
</div>
|
||||
<div class="pool-category-card matched" onclick="showPoolList('matched')">
|
||||
<div class="pool-category-fallback matched" id="pool-matched-bg"></div>
|
||||
<div class="pool-category-overlay"></div>
|
||||
<div class="pool-category-content">
|
||||
<div class="pool-category-icon">✓</div>
|
||||
<div class="pool-category-count matched" id="pool-cat-matched-count">${matchedCount}</div>
|
||||
<div class="pool-category-label">cached matches</div>
|
||||
</div>
|
||||
<div class="pool-category-top-bar matched"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pool-list-view" id="pool-list-view" style="display: none;">
|
||||
<div class="pool-list-header">
|
||||
<button class="pool-back-btn" onclick="showPoolCategories()">← Back</button>
|
||||
<span class="pool-list-title" id="pool-list-title"></span>
|
||||
</div>
|
||||
<div class="pool-list-content" id="pool-list-content"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="playlist-modal-footer">
|
||||
<div class="playlist-modal-footer-left"></div>
|
||||
<div class="playlist-modal-footer-right">
|
||||
<button class="playlist-modal-btn playlist-modal-btn-secondary" onclick="closeDiscoveryPoolModal()">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(overlay);
|
||||
overlay.style.display = 'flex';
|
||||
_discoveryPoolOverlay = overlay;
|
||||
|
||||
// Build matched mosaic if images available
|
||||
_buildPoolMatchedMosaic();
|
||||
}
|
||||
|
||||
function _buildPoolMatchedMosaic() {
|
||||
const entries = _discoveryPoolData.matched || [];
|
||||
const images = [];
|
||||
for (const e of entries) {
|
||||
const md = e.matched_data || {};
|
||||
if (md.image_url && images.indexOf(md.image_url) === -1) {
|
||||
images.push(md.image_url);
|
||||
if (images.length >= 20) break;
|
||||
}
|
||||
}
|
||||
const bgEl = document.getElementById('pool-matched-bg');
|
||||
if (!bgEl || images.length < 4) return; // keep fallback gradient
|
||||
|
||||
// Build mosaic rows similar to wishlist
|
||||
bgEl.innerHTML = '';
|
||||
bgEl.className = 'wishlist-mosaic-background';
|
||||
const rows = 4;
|
||||
const imgPerRow = Math.ceil(images.length / rows) * 2; // duplicate for seamless loop
|
||||
for (let r = 0; r < rows; r++) {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'wishlist-mosaic-row-wrapper';
|
||||
const row = document.createElement('div');
|
||||
row.className = 'wishlist-mosaic-row' + (r % 2 === 1 ? ' scroll-right' : '');
|
||||
row.style.setProperty('--speed', (25 + r * 5) + 's');
|
||||
row.style.animationDelay = (r * 0.15) + 's';
|
||||
for (let i = 0; i < imgPerRow; i++) {
|
||||
const img = images[(i + r * 3) % images.length];
|
||||
const tile = document.createElement('div');
|
||||
tile.className = 'wishlist-mosaic-tile';
|
||||
tile.innerHTML = `<div class="wishlist-mosaic-image" style="background-image: url('${img}')"></div>`;
|
||||
row.appendChild(tile);
|
||||
}
|
||||
wrapper.appendChild(row);
|
||||
bgEl.appendChild(wrapper);
|
||||
}
|
||||
}
|
||||
|
||||
function closeDiscoveryPoolModal() {
|
||||
if (_discoveryPoolOverlay) {
|
||||
_discoveryPoolOverlay.remove();
|
||||
_discoveryPoolOverlay = null;
|
||||
}
|
||||
_discoveryPoolData = null;
|
||||
// Refresh dashboard stats
|
||||
loadDiscoveryPoolStats();
|
||||
}
|
||||
|
||||
function showPoolCategories() {
|
||||
_discoveryPoolView = 'categories';
|
||||
const grid = document.getElementById('pool-category-grid');
|
||||
const list = document.getElementById('pool-list-view');
|
||||
if (grid) grid.style.display = '';
|
||||
if (list) list.style.display = 'none';
|
||||
}
|
||||
|
||||
function showPoolList(category) {
|
||||
_discoveryPoolView = category;
|
||||
const grid = document.getElementById('pool-category-grid');
|
||||
const list = document.getElementById('pool-list-view');
|
||||
if (grid) grid.style.display = 'none';
|
||||
if (list) list.style.display = '';
|
||||
|
||||
const titleEl = document.getElementById('pool-list-title');
|
||||
if (titleEl) titleEl.textContent = category === 'failed' ? 'Failed Tracks' : 'Matched Tracks';
|
||||
|
||||
renderPoolList();
|
||||
}
|
||||
|
||||
async function filterDiscoveryPool(playlistId) {
|
||||
_discoveryPoolPlaylistFilter = playlistId || null;
|
||||
let url = '/api/discovery-pool';
|
||||
if (playlistId) url += `?playlist_id=${playlistId}`;
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
_discoveryPoolData = await res.json();
|
||||
// Update header counts
|
||||
_updatePoolHeaderCounts();
|
||||
// Update category card counts
|
||||
const failedCountEl = document.getElementById('pool-cat-failed-count');
|
||||
const matchedCountEl = document.getElementById('pool-cat-matched-count');
|
||||
if (failedCountEl) failedCountEl.textContent = _discoveryPoolData.stats.failed || 0;
|
||||
if (matchedCountEl) matchedCountEl.textContent = _discoveryPoolData.stats.matched || 0;
|
||||
// If viewing a list, refresh it
|
||||
if (_discoveryPoolView === 'failed' || _discoveryPoolView === 'matched') {
|
||||
renderPoolList();
|
||||
}
|
||||
} catch (err) {
|
||||
showToast('Failed to filter discovery pool', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function _updatePoolHeaderCounts() {
|
||||
if (!_discoveryPoolData) return;
|
||||
const failedCount = _discoveryPoolData.stats.failed || 0;
|
||||
const matchedCount = _discoveryPoolData.stats.matched || 0;
|
||||
const matchedEl = document.getElementById('pool-header-matched');
|
||||
const failedEl = document.getElementById('pool-header-failed');
|
||||
if (matchedEl) matchedEl.textContent = `${matchedCount} Matched`;
|
||||
if (failedEl) {
|
||||
failedEl.textContent = `${failedCount} Failed`;
|
||||
failedEl.classList.toggle('pool-header-failed-highlight', failedCount > 0);
|
||||
}
|
||||
}
|
||||
|
||||
function renderPoolList() {
|
||||
const container = document.getElementById('pool-list-content');
|
||||
if (!container || !_discoveryPoolData) return;
|
||||
|
||||
if (_discoveryPoolView === 'failed') {
|
||||
const tracks = _discoveryPoolData.failed || [];
|
||||
if (tracks.length === 0) {
|
||||
container.innerHTML = '<div class="pool-empty">No failed discoveries. All tracks matched successfully.</div>';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = tracks.map(t => `
|
||||
<div class="pool-track-row pool-failed">
|
||||
<div class="pool-track-info">
|
||||
<div class="pool-track-name">${_esc(t.track_name)}</div>
|
||||
<div class="pool-track-meta">
|
||||
<span class="pool-track-artist">${_esc(t.artist_name)}</span>
|
||||
<span class="pool-track-playlist-badge">${_esc(t.playlist_name)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="playlist-modal-btn playlist-modal-btn-primary pool-fix-btn" onclick="openPoolFixModal(${t.id}, '${_escAttr(t.track_name)}', '${_escAttr(t.artist_name)}')">Fix Match</button>
|
||||
</div>
|
||||
`).join('');
|
||||
} else {
|
||||
const entries = _discoveryPoolData.matched || [];
|
||||
if (entries.length === 0) {
|
||||
container.innerHTML = '<div class="pool-empty">No cached discovery matches yet.</div>';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = entries.map(e => {
|
||||
const md = e.matched_data || {};
|
||||
const matchedArtists = (md.artists || []).map(a => typeof a === 'string' ? a : (a.name || '')).join(', ');
|
||||
const conf = Math.round((e.confidence || 0) * 100);
|
||||
const confClass = conf >= 80 ? 'high' : (conf >= 70 ? 'mid' : 'low');
|
||||
const imgUrl = md.image_url || '';
|
||||
return `
|
||||
<div class="pool-track-row pool-matched">
|
||||
${imgUrl ? `<img class="pool-match-image" src="${_esc(imgUrl)}" alt="" onerror="this.style.display='none'" />` : '<div class="pool-match-image-placeholder"></div>'}
|
||||
<div class="pool-track-info">
|
||||
<div class="pool-track-name">${_esc(e.original_title)}</div>
|
||||
<div class="pool-track-meta">
|
||||
<span class="pool-track-artist">${_esc(e.original_artist)}</span>
|
||||
<span class="pool-track-arrow">→</span>
|
||||
<span class="pool-match-name">${_esc(md.name || '?')}</span>
|
||||
<span class="pool-match-provider">${_esc(e.provider)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="pool-confidence-badge ${confClass}">${conf}%</span>
|
||||
<span class="pool-use-count">${e.use_count}×</span>
|
||||
<button class="pool-remove-btn" onclick="removePoolCacheEntry(${e.id})" title="Remove cached match">×</button>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
}
|
||||
|
||||
async function removePoolCacheEntry(entryId) {
|
||||
if (!confirm('Remove this cached match? The track will be re-discovered fresh next time.')) return;
|
||||
try {
|
||||
const res = await fetch(`/api/discovery-pool/cache/${entryId}`, { method: 'DELETE' });
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
showToast('Cache entry removed', 'success');
|
||||
filterDiscoveryPool(_discoveryPoolPlaylistFilter || '');
|
||||
} else {
|
||||
showToast(data.error || 'Failed to remove', 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
showToast(`Error: ${err.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// --- Pool Fix Sub-Modal ---
|
||||
|
||||
function openPoolFixModal(trackId, trackName, artistName) {
|
||||
// Create sub-modal overlay inside the pool modal
|
||||
let fixOverlay = document.getElementById('pool-fix-overlay');
|
||||
if (fixOverlay) fixOverlay.remove();
|
||||
|
||||
fixOverlay = document.createElement('div');
|
||||
fixOverlay.className = 'pool-fix-overlay';
|
||||
fixOverlay.id = 'pool-fix-overlay';
|
||||
fixOverlay.onclick = (e) => { if (e.target === fixOverlay) closePoolFixModal(); };
|
||||
|
||||
fixOverlay.innerHTML = `
|
||||
<div class="pool-fix-modal">
|
||||
<div class="discovery-fix-modal-header">
|
||||
<h2>Fix Track Match</h2>
|
||||
<button class="modal-close-btn" onclick="closePoolFixModal()">✕</button>
|
||||
</div>
|
||||
<div class="discovery-fix-modal-content">
|
||||
<div class="source-track-info">
|
||||
<h3>Source Track</h3>
|
||||
<div class="source-track-display">
|
||||
<div class="source-field"><label>Track:</label><span>${_esc(trackName)}</span></div>
|
||||
<div class="source-field"><label>Artist:</label><span>${_esc(artistName)}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="search-inputs-section">
|
||||
<h3>Search for Match</h3>
|
||||
<div class="search-input-group">
|
||||
<input type="text" id="pool-fix-track-input" placeholder="Track name" class="fix-modal-input" value="${_escAttr(trackName)}">
|
||||
<input type="text" id="pool-fix-artist-input" placeholder="Artist name" class="fix-modal-input" value="${_escAttr(artistName)}">
|
||||
<button class="search-btn" onclick="searchPoolFix()">Search</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="search-results-section">
|
||||
<h3>Results</h3>
|
||||
<div id="pool-fix-results" class="fix-modal-results">
|
||||
<div class="pool-empty">Searching...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="discovery-fix-modal-footer">
|
||||
<button class="modal-btn secondary" onclick="closePoolFixModal()">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
fixOverlay.dataset.trackId = trackId;
|
||||
document.body.appendChild(fixOverlay);
|
||||
|
||||
// Add enter key support
|
||||
const trackInput = fixOverlay.querySelector('#pool-fix-track-input');
|
||||
const artistInput = fixOverlay.querySelector('#pool-fix-artist-input');
|
||||
const enterHandler = (e) => { if (e.key === 'Enter') searchPoolFix(); };
|
||||
trackInput.addEventListener('keypress', enterHandler);
|
||||
artistInput.addEventListener('keypress', enterHandler);
|
||||
|
||||
// Auto-search
|
||||
setTimeout(() => searchPoolFix(), 100);
|
||||
}
|
||||
|
||||
function closePoolFixModal() {
|
||||
const fixOverlay = document.getElementById('pool-fix-overlay');
|
||||
if (fixOverlay) fixOverlay.remove();
|
||||
}
|
||||
|
||||
async function searchPoolFix() {
|
||||
const trackInput = document.getElementById('pool-fix-track-input');
|
||||
const artistInput = document.getElementById('pool-fix-artist-input');
|
||||
const resultsContainer = document.getElementById('pool-fix-results');
|
||||
if (!trackInput || !resultsContainer) return;
|
||||
|
||||
const query = `${artistInput.value.trim()} ${trackInput.value.trim()}`.trim();
|
||||
if (!query) {
|
||||
resultsContainer.innerHTML = '<div class="pool-empty">Enter a search term</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
resultsContainer.innerHTML = '<div class="pool-empty">Searching...</div>';
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/spotify/search_tracks?query=${encodeURIComponent(query)}&limit=20`);
|
||||
const data = await res.json();
|
||||
const tracks = data.tracks || [];
|
||||
|
||||
if (tracks.length === 0) {
|
||||
resultsContainer.innerHTML = '<div class="pool-empty">No results found</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
resultsContainer.innerHTML = tracks.map((track, i) => {
|
||||
const artists = (track.artists || []).join(', ');
|
||||
const duration = track.duration_ms ? formatDuration(track.duration_ms) : '';
|
||||
return `
|
||||
<div class="fix-result-card" onclick='selectPoolFixTrack(${JSON.stringify(track).replace(/'/g, "'")})'>
|
||||
<div class="fix-result-card-content">
|
||||
<div class="fix-result-title">${_esc(track.name || 'Unknown')}</div>
|
||||
<div class="fix-result-artist">${_esc(artists)}</div>
|
||||
<div class="fix-result-album">${_esc(track.album || '')}</div>
|
||||
${duration ? `<div class="fix-result-duration">${duration}</div>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
} catch (err) {
|
||||
resultsContainer.innerHTML = `<div class="pool-empty">Search failed: ${_esc(err.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function selectPoolFixTrack(track) {
|
||||
const fixOverlay = document.getElementById('pool-fix-overlay');
|
||||
if (!fixOverlay) return;
|
||||
const trackId = parseInt(fixOverlay.dataset.trackId);
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/discovery-pool/fix', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
track_id: trackId,
|
||||
spotify_track: track,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
showToast(`Matched: ${track.name}`, 'success');
|
||||
closePoolFixModal();
|
||||
// Refresh pool data
|
||||
filterDiscoveryPool(_discoveryPoolPlaylistFilter || '');
|
||||
} else {
|
||||
showToast(data.error || 'Failed to fix track', 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
showToast(`Error: ${err.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteMirroredPlaylist(playlistId, name) {
|
||||
if (!confirm(`Delete mirrored playlist "${name}"?`)) return;
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -6010,6 +6010,488 @@ body {
|
|||
color: #22c55e;
|
||||
}
|
||||
|
||||
/* Discovery Pool trigger button (secondary - dashboard card is primary entry) */
|
||||
.pool-trigger-btn {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
padding: 4px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.pool-trigger-btn:hover {
|
||||
background: rgba(var(--accent-rgb), 0.1);
|
||||
border-color: rgba(var(--accent-rgb), 0.2);
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
/* ===== Discovery Pool — Premium Modal ===== */
|
||||
|
||||
/* Playlist filter in header */
|
||||
.pool-playlist-filter {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
padding: 5px 10px;
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.pool-playlist-filter:focus {
|
||||
border-color: rgba(var(--accent-rgb), 0.4);
|
||||
}
|
||||
|
||||
.pool-playlist-filter option {
|
||||
background: #1a1a1a;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Header failed highlight */
|
||||
.pool-header-failed-highlight {
|
||||
color: #ef4444 !important;
|
||||
background: rgba(239, 68, 68, 0.12) !important;
|
||||
border-radius: 12px;
|
||||
padding: 4px 10px !important;
|
||||
}
|
||||
|
||||
/* ===== Category Grid ===== */
|
||||
.pool-category-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 20px;
|
||||
padding: 28px;
|
||||
}
|
||||
|
||||
.pool-category-card {
|
||||
background: linear-gradient(135deg,
|
||||
rgba(26, 26, 26, 0.95) 0%,
|
||||
rgba(18, 18, 18, 0.98) 100%);
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
padding: 32px 24px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
min-height: 250px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.pool-category-card .pool-category-top-bar {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 4px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.pool-category-card .pool-category-top-bar.failed {
|
||||
background: linear-gradient(90deg, #ef4444, #f97316);
|
||||
}
|
||||
|
||||
.pool-category-card .pool-category-top-bar.matched {
|
||||
background: linear-gradient(90deg, #22c55e, rgb(var(--accent-light-rgb)));
|
||||
}
|
||||
|
||||
.pool-category-card:hover .pool-category-top-bar {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Failed card accent */
|
||||
.pool-category-card.failed {
|
||||
border-color: rgba(239, 68, 68, 0.12);
|
||||
}
|
||||
|
||||
.pool-category-card.failed:hover {
|
||||
border-color: rgba(239, 68, 68, 0.35);
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 8px 24px rgba(239, 68, 68, 0.2);
|
||||
}
|
||||
|
||||
/* Matched card accent */
|
||||
.pool-category-card.matched {
|
||||
border-color: rgba(34, 197, 94, 0.12);
|
||||
}
|
||||
|
||||
.pool-category-card.matched:hover {
|
||||
border-color: rgba(34, 197, 94, 0.35);
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 8px 24px rgba(34, 197, 94, 0.2);
|
||||
}
|
||||
|
||||
/* Fallback gradient backgrounds */
|
||||
.pool-category-fallback {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 0;
|
||||
opacity: 0;
|
||||
animation: fadeInRow 1s ease-out forwards, gradientShift 8s ease infinite;
|
||||
}
|
||||
|
||||
.pool-category-fallback.failed {
|
||||
background: linear-gradient(135deg,
|
||||
rgba(239, 68, 68, 0.25) 0%,
|
||||
rgba(249, 115, 22, 0.2) 50%,
|
||||
rgba(239, 68, 68, 0.15) 100%);
|
||||
background-size: 200% 200%;
|
||||
}
|
||||
|
||||
.pool-category-fallback.matched {
|
||||
background: linear-gradient(135deg,
|
||||
rgba(34, 197, 94, 0.25) 0%,
|
||||
rgba(var(--accent-rgb), 0.2) 50%,
|
||||
rgba(34, 197, 94, 0.15) 100%);
|
||||
background-size: 200% 200%;
|
||||
}
|
||||
|
||||
/* Content overlay for text readability */
|
||||
.pool-category-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(135deg,
|
||||
rgba(0, 0, 0, 0.82) 0%,
|
||||
rgba(0, 0, 0, 0.72) 100%);
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.pool-category-content {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.pool-category-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.pool-category-card.failed .pool-category-icon {
|
||||
filter: drop-shadow(0 4px 12px rgba(239, 68, 68, 0.4));
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.pool-category-card.matched .pool-category-icon {
|
||||
filter: drop-shadow(0 4px 12px rgba(34, 197, 94, 0.4));
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.pool-category-count {
|
||||
font-size: 36px;
|
||||
font-weight: 700;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.pool-category-count.failed {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.pool-category-count.matched {
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.pool-category-label {
|
||||
font-size: 14px;
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Matched card mosaic hover */
|
||||
.pool-category-card.matched:hover .wishlist-mosaic-image {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.pool-category-card.matched:hover .pool-category-overlay {
|
||||
background: linear-gradient(135deg,
|
||||
rgba(34, 197, 94, 0.12) 0%,
|
||||
rgba(0, 0, 0, 0.78) 100%);
|
||||
}
|
||||
|
||||
.pool-category-card.failed:hover .pool-category-overlay {
|
||||
background: linear-gradient(135deg,
|
||||
rgba(239, 68, 68, 0.12) 0%,
|
||||
rgba(0, 0, 0, 0.78) 100%);
|
||||
}
|
||||
|
||||
/* ===== List View ===== */
|
||||
.pool-list-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
padding: 0 28px;
|
||||
}
|
||||
|
||||
.pool-list-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding: 16px 0;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
margin-bottom: 8px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.pool-back-btn {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
color: #e0e0e0;
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.pool-back-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
color: #ffffff;
|
||||
transform: translateX(-2px);
|
||||
}
|
||||
|
||||
.pool-list-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #ffffff;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.pool-list-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding-right: 8px;
|
||||
max-height: 55vh;
|
||||
}
|
||||
|
||||
.pool-list-content::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.pool-list-content::-webkit-scrollbar-track {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.pool-list-content::-webkit-scrollbar-thumb {
|
||||
background: rgba(var(--accent-rgb), 0.4);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* ===== Track Rows (Premium) ===== */
|
||||
.pool-empty {
|
||||
text-align: center;
|
||||
color: rgba(255, 255, 255, 0.3);
|
||||
padding: 40px 20px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.pool-track-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 8px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
|
||||
border-radius: 8px;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.pool-track-row:hover {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.pool-track-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.pool-track-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.pool-track-name {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.pool-track-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 4px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.pool-track-artist {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
|
||||
.pool-track-playlist-badge {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: rgba(var(--accent-rgb), 0.8);
|
||||
background: rgba(var(--accent-rgb), 0.1);
|
||||
border: 1px solid rgba(var(--accent-rgb), 0.2);
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.pool-track-arrow {
|
||||
color: rgba(var(--accent-rgb), 0.5);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.pool-match-name {
|
||||
font-size: 12px;
|
||||
color: rgb(var(--accent-light-rgb));
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.pool-match-provider {
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.8px;
|
||||
color: rgba(255, 255, 255, 0.35);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* Album art thumbnail */
|
||||
.pool-match-image {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 6px;
|
||||
flex-shrink: 0;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.pool-match-image-placeholder {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 6px;
|
||||
flex-shrink: 0;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
/* Confidence badge */
|
||||
.pool-confidence-badge {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
padding: 3px 10px;
|
||||
border-radius: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.pool-confidence-badge.high {
|
||||
color: #22c55e;
|
||||
background: rgba(34, 197, 94, 0.12);
|
||||
}
|
||||
|
||||
.pool-confidence-badge.mid {
|
||||
color: #eab308;
|
||||
background: rgba(234, 179, 8, 0.12);
|
||||
}
|
||||
|
||||
.pool-confidence-badge.low {
|
||||
color: #ef4444;
|
||||
background: rgba(239, 68, 68, 0.12);
|
||||
}
|
||||
|
||||
/* Use count */
|
||||
.pool-use-count {
|
||||
font-size: 11px;
|
||||
color: rgba(255, 255, 255, 0.3);
|
||||
flex-shrink: 0;
|
||||
min-width: 30px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* Fix button in list */
|
||||
.pool-fix-btn {
|
||||
flex-shrink: 0;
|
||||
font-size: 12px !important;
|
||||
padding: 6px 14px !important;
|
||||
}
|
||||
|
||||
/* Remove button */
|
||||
.pool-remove-btn {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
color: rgba(255, 255, 255, 0.3);
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.pool-remove-btn:hover {
|
||||
color: #ef4444;
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
border-color: rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
|
||||
/* ===== Fix Sub-Modal (kept) ===== */
|
||||
.pool-fix-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
backdrop-filter: blur(4px);
|
||||
z-index: 10001;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
animation: fadeIn 0.2s ease;
|
||||
}
|
||||
|
||||
.pool-fix-modal {
|
||||
background: linear-gradient(135deg, rgba(26, 26, 26, 0.98) 0%, rgba(18, 18, 18, 0.99) 100%);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 16px;
|
||||
width: 90%;
|
||||
max-width: 700px;
|
||||
max-height: 80vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
|
||||
animation: slideUp 0.25s ease;
|
||||
}
|
||||
|
||||
/* Mirrored playlist track modal */
|
||||
.mirrored-modal-overlay {
|
||||
position: fixed;
|
||||
|
|
|
|||
Loading…
Reference in a new issue