Add per-track ownership indicators to Library wishlist modal
When clicking a partially-complete album on the Library page, the wishlist modal now shows which tracks are owned (dimmed with checkmark) and which are missing (orange border). Ownership data lazy-loads after the modal opens to avoid blocking the UI, using a batch DB query for speed. Also fixes albums like DAMN. showing "14/15" when all available tracks are owned — the frontend now trusts the backend's "completed" status instead of doing raw track count math against potentially inaccurate Spotify metadata.
This commit is contained in:
parent
f7c929abec
commit
20c7aa33a3
3 changed files with 238 additions and 18 deletions
|
|
@ -5849,6 +5849,93 @@ def library_completion_stream():
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@app.route('/api/library/check-tracks', methods=['POST'])
|
||||||
|
def library_check_tracks():
|
||||||
|
"""Check which tracks from a list are already owned in the library.
|
||||||
|
Uses a single batch DB query + in-memory fuzzy matching for speed."""
|
||||||
|
try:
|
||||||
|
data = request.get_json()
|
||||||
|
if not data or 'artist_name' not in data or 'tracks' not in data:
|
||||||
|
return jsonify({"success": False, "error": "Missing artist_name or tracks"}), 400
|
||||||
|
|
||||||
|
artist_name = data['artist_name']
|
||||||
|
tracks = data['tracks']
|
||||||
|
|
||||||
|
from database.music_database import MusicDatabase
|
||||||
|
db = MusicDatabase()
|
||||||
|
active_server = config_manager.get_active_media_server()
|
||||||
|
|
||||||
|
# Single query: get ALL tracks by this artist from the DB
|
||||||
|
db_tracks = db.search_tracks(artist=artist_name, limit=500, server_source=active_server)
|
||||||
|
|
||||||
|
if not db_tracks:
|
||||||
|
# No tracks by this artist in DB — none owned
|
||||||
|
owned_map = {t.get('name', ''): False for t in tracks if t.get('name')}
|
||||||
|
return jsonify({"success": True, "owned_tracks": owned_map})
|
||||||
|
|
||||||
|
# Pre-normalize all DB track titles for fast in-memory comparison
|
||||||
|
from difflib import SequenceMatcher
|
||||||
|
try:
|
||||||
|
from unidecode import unidecode
|
||||||
|
except ImportError:
|
||||||
|
unidecode = lambda x: x
|
||||||
|
|
||||||
|
def _normalize(text):
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
return unidecode(text).lower().strip()
|
||||||
|
|
||||||
|
def _clean_title(text):
|
||||||
|
import re
|
||||||
|
cleaned = _normalize(text)
|
||||||
|
# Remove parenthetical/bracket content, dashes, feat/ft, remaster tags
|
||||||
|
cleaned = re.sub(r'\s*[\[\(].*?[\]\)]', '', cleaned)
|
||||||
|
cleaned = re.sub(r'\s*-\s*', ' ', cleaned)
|
||||||
|
cleaned = re.sub(r'\s*feat\..*', '', cleaned)
|
||||||
|
cleaned = re.sub(r'\s*featuring.*', '', cleaned)
|
||||||
|
cleaned = re.sub(r'\s*ft\..*', '', cleaned)
|
||||||
|
cleaned = re.sub(r'\s*\d{4}\s*remaster.*', '', cleaned)
|
||||||
|
cleaned = re.sub(r'\s*remaster(ed)?.*', '', cleaned)
|
||||||
|
cleaned = re.sub(r'\s+', ' ', cleaned).strip()
|
||||||
|
return cleaned
|
||||||
|
|
||||||
|
# Pre-compute normalized DB titles once
|
||||||
|
db_title_pairs = [(_normalize(t.title), _clean_title(t.title)) for t in db_tracks]
|
||||||
|
|
||||||
|
owned_map = {}
|
||||||
|
for track in tracks:
|
||||||
|
track_name = track.get('name', '')
|
||||||
|
if not track_name:
|
||||||
|
continue
|
||||||
|
|
||||||
|
search_norm = _normalize(track_name)
|
||||||
|
search_clean = _clean_title(track_name)
|
||||||
|
is_owned = False
|
||||||
|
|
||||||
|
for db_norm, db_clean in db_title_pairs:
|
||||||
|
# Check normalized match first (fast path for exact/near-exact)
|
||||||
|
if search_norm == db_norm or search_clean == db_clean:
|
||||||
|
is_owned = True
|
||||||
|
break
|
||||||
|
# Fuzzy match: try both normalized and cleaned
|
||||||
|
sim = max(
|
||||||
|
SequenceMatcher(None, search_norm, db_norm).ratio(),
|
||||||
|
SequenceMatcher(None, search_clean, db_clean).ratio()
|
||||||
|
)
|
||||||
|
if sim >= 0.7:
|
||||||
|
is_owned = True
|
||||||
|
break
|
||||||
|
|
||||||
|
owned_map[track_name] = is_owned
|
||||||
|
|
||||||
|
return jsonify({"success": True, "owned_tracks": owned_map})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Error checking track ownership: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
return jsonify({"success": False, "error": str(e)}), 500
|
||||||
|
|
||||||
@app.route('/api/stream/start', methods=['POST'])
|
@app.route('/api/stream/start', methods=['POST'])
|
||||||
def stream_start():
|
def stream_start():
|
||||||
"""Start streaming a track in the background"""
|
"""Start streaming a track in the background"""
|
||||||
|
|
|
||||||
|
|
@ -10579,6 +10579,7 @@ function getSuccessfulDownloadCount(process) {
|
||||||
// ===============================
|
// ===============================
|
||||||
|
|
||||||
let currentWishlistModalData = null;
|
let currentWishlistModalData = null;
|
||||||
|
let wishlistModalVersion = 0;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Open the Add to Wishlist modal for an album/EP/single
|
* Open the Add to Wishlist modal for an album/EP/single
|
||||||
|
|
@ -10587,7 +10588,8 @@ let currentWishlistModalData = null;
|
||||||
* @param {Array} tracks - Array of track objects
|
* @param {Array} tracks - Array of track objects
|
||||||
* @param {string} albumType - Type of release (album, EP, single)
|
* @param {string} albumType - Type of release (album, EP, single)
|
||||||
*/
|
*/
|
||||||
async function openAddToWishlistModal(album, artist, tracks, albumType) {
|
async function openAddToWishlistModal(album, artist, tracks, albumType, trackOwnership) {
|
||||||
|
wishlistModalVersion++;
|
||||||
showLoadingOverlay('Preparing wishlist...');
|
showLoadingOverlay('Preparing wishlist...');
|
||||||
console.log(`🎵 Opening Add to Wishlist modal for: ${artist.name} - ${album.name}`);
|
console.log(`🎵 Opening Add to Wishlist modal for: ${artist.name} - ${album.name}`);
|
||||||
|
|
||||||
|
|
@ -10609,14 +10611,14 @@ async function openAddToWishlistModal(album, artist, tracks, albumType) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate and populate hero section
|
// Generate and populate hero section
|
||||||
const heroContent = generateWishlistModalHeroSection(album, artist, tracks, albumType);
|
const heroContent = generateWishlistModalHeroSection(album, artist, tracks, albumType, trackOwnership);
|
||||||
const heroContainer = document.getElementById('add-to-wishlist-modal-hero');
|
const heroContainer = document.getElementById('add-to-wishlist-modal-hero');
|
||||||
if (heroContainer) {
|
if (heroContainer) {
|
||||||
heroContainer.innerHTML = heroContent;
|
heroContainer.innerHTML = heroContent;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate and populate track list
|
// Generate and populate track list
|
||||||
const trackListHTML = generateWishlistTrackList(tracks);
|
const trackListHTML = generateWishlistTrackList(tracks, trackOwnership);
|
||||||
const trackListContainer = document.getElementById('wishlist-track-list');
|
const trackListContainer = document.getElementById('wishlist-track-list');
|
||||||
if (trackListContainer) {
|
if (trackListContainer) {
|
||||||
trackListContainer.innerHTML = trackListHTML;
|
trackListContainer.innerHTML = trackListHTML;
|
||||||
|
|
@ -10644,11 +10646,21 @@ async function openAddToWishlistModal(album, artist, tracks, albumType) {
|
||||||
/**
|
/**
|
||||||
* Generate the hero section HTML for the wishlist modal
|
* Generate the hero section HTML for the wishlist modal
|
||||||
*/
|
*/
|
||||||
function generateWishlistModalHeroSection(album, artist, tracks, albumType) {
|
function generateWishlistModalHeroSection(album, artist, tracks, albumType, trackOwnership) {
|
||||||
const artistImage = artist.image_url || '';
|
const artistImage = artist.image_url || '';
|
||||||
const albumImage = album.image_url || '';
|
const albumImage = album.image_url || '';
|
||||||
const trackCount = tracks.length;
|
const trackCount = tracks.length;
|
||||||
|
|
||||||
|
// Calculate missing tracks if ownership info is available
|
||||||
|
let trackDetailText = `${trackCount} track${trackCount !== 1 ? 's' : ''}`;
|
||||||
|
if (trackOwnership) {
|
||||||
|
const ownedCount = Object.values(trackOwnership).filter(v => v === true).length;
|
||||||
|
const missingCount = trackCount - ownedCount;
|
||||||
|
if (missingCount > 0 && ownedCount > 0) {
|
||||||
|
trackDetailText = `${missingCount} of ${trackCount} tracks missing`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let heroBackgroundImage = '';
|
let heroBackgroundImage = '';
|
||||||
if (albumImage) {
|
if (albumImage) {
|
||||||
heroBackgroundImage = `<div class="add-to-wishlist-modal-hero-bg" style="background-image: url('${albumImage}');"></div>`;
|
heroBackgroundImage = `<div class="add-to-wishlist-modal-hero-bg" style="background-image: url('${albumImage}');"></div>`;
|
||||||
|
|
@ -10665,7 +10677,7 @@ function generateWishlistModalHeroSection(album, artist, tracks, albumType) {
|
||||||
<div class="add-to-wishlist-modal-hero-subtitle">by ${escapeHtml(artist.name || 'Unknown Artist')}</div>
|
<div class="add-to-wishlist-modal-hero-subtitle">by ${escapeHtml(artist.name || 'Unknown Artist')}</div>
|
||||||
<div class="add-to-wishlist-modal-hero-details">
|
<div class="add-to-wishlist-modal-hero-details">
|
||||||
<span class="add-to-wishlist-modal-hero-detail">${albumType || 'Album'}</span>
|
<span class="add-to-wishlist-modal-hero-detail">${albumType || 'Album'}</span>
|
||||||
<span class="add-to-wishlist-modal-hero-detail">${trackCount} track${trackCount !== 1 ? 's' : ''}</span>
|
<span class="add-to-wishlist-modal-hero-detail">${trackDetailText}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -10680,7 +10692,7 @@ function generateWishlistModalHeroSection(album, artist, tracks, albumType) {
|
||||||
/**
|
/**
|
||||||
* Generate the track list HTML for the wishlist modal
|
* Generate the track list HTML for the wishlist modal
|
||||||
*/
|
*/
|
||||||
function generateWishlistTrackList(tracks) {
|
function generateWishlistTrackList(tracks, trackOwnership) {
|
||||||
if (!tracks || tracks.length === 0) {
|
if (!tracks || tracks.length === 0) {
|
||||||
return '<div style="text-align: center; padding: 40px; color: rgba(255, 255, 255, 0.6);">No tracks found</div>';
|
return '<div style="text-align: center; padding: 40px; color: rgba(255, 255, 255, 0.6);">No tracks found</div>';
|
||||||
}
|
}
|
||||||
|
|
@ -10691,14 +10703,21 @@ function generateWishlistTrackList(tracks) {
|
||||||
const artistsString = formatArtists(track.artists) || 'Unknown Artist';
|
const artistsString = formatArtists(track.artists) || 'Unknown Artist';
|
||||||
const duration = formatDuration(track.duration_ms);
|
const duration = formatDuration(track.duration_ms);
|
||||||
|
|
||||||
|
const isOwned = trackOwnership ? trackOwnership[track.name] === true : null;
|
||||||
|
const ownershipClass = isOwned === true ? 'owned' : (isOwned === false ? 'missing' : '');
|
||||||
|
const badge = isOwned === true
|
||||||
|
? '<div class="wishlist-track-badge owned"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg></div>'
|
||||||
|
: '';
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="wishlist-track-item">
|
<div class="wishlist-track-item ${ownershipClass}">
|
||||||
<div class="wishlist-track-number">${trackNumber}</div>
|
<div class="wishlist-track-number">${trackNumber}</div>
|
||||||
<div class="wishlist-track-info">
|
<div class="wishlist-track-info">
|
||||||
<div class="wishlist-track-name">${trackName}</div>
|
<div class="wishlist-track-name">${trackName}</div>
|
||||||
<div class="wishlist-track-artists">${artistsString}</div>
|
<div class="wishlist-track-artists">${artistsString}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="wishlist-track-duration">${duration}</div>
|
<div class="wishlist-track-duration">${duration}</div>
|
||||||
|
${badge}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
@ -10843,6 +10862,84 @@ async function handleAddToWishlist() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lazy-load per-track ownership indicators into an already-open wishlist modal.
|
||||||
|
* Fetches ownership from the backend, then updates the modal DOM in-place.
|
||||||
|
* If all tracks are owned (Spotify metadata discrepancy), also fixes the source card.
|
||||||
|
*/
|
||||||
|
async function lazyLoadTrackOwnership(artistName, tracks, sourceCard) {
|
||||||
|
const myVersion = wishlistModalVersion;
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/library/check-tracks', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
artist_name: artistName,
|
||||||
|
tracks: tracks.map(t => ({ name: t.name, track_number: t.track_number }))
|
||||||
|
})
|
||||||
|
});
|
||||||
|
const data = await resp.json();
|
||||||
|
if (!data.success) return;
|
||||||
|
|
||||||
|
// Guard against stale updates if user reopened modal for a different album
|
||||||
|
if (myVersion !== wishlistModalVersion) return;
|
||||||
|
|
||||||
|
const ownership = data.owned_tracks;
|
||||||
|
const trackItems = document.querySelectorAll('#wishlist-track-list .wishlist-track-item');
|
||||||
|
|
||||||
|
let ownedCount = 0;
|
||||||
|
trackItems.forEach((item, index) => {
|
||||||
|
const track = tracks[index];
|
||||||
|
if (!track) return;
|
||||||
|
const isOwned = ownership[track.name] === true;
|
||||||
|
if (isOwned) {
|
||||||
|
ownedCount++;
|
||||||
|
item.classList.add('owned');
|
||||||
|
const badge = document.createElement('div');
|
||||||
|
badge.className = 'wishlist-track-badge owned';
|
||||||
|
badge.innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg>';
|
||||||
|
item.appendChild(badge);
|
||||||
|
} else {
|
||||||
|
item.classList.add('missing');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update hero subtitle with missing count
|
||||||
|
const missingCount = tracks.length - ownedCount;
|
||||||
|
const heroDetails = document.querySelectorAll('.add-to-wishlist-modal-hero-detail');
|
||||||
|
const trackDetailEl = heroDetails.length > 1 ? heroDetails[heroDetails.length - 1] : null;
|
||||||
|
if (trackDetailEl && missingCount > 0 && ownedCount > 0) {
|
||||||
|
trackDetailEl.textContent = `${missingCount} of ${tracks.length} tracks missing`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If ALL returned tracks are owned, this is a Spotify metadata discrepancy
|
||||||
|
// (e.g. total_tracks says 15 but API only returns 14, and all 14 are owned)
|
||||||
|
// Fix the source card to show complete
|
||||||
|
if (missingCount === 0 && sourceCard && sourceCard._releaseData) {
|
||||||
|
sourceCard._releaseData.track_completion = {
|
||||||
|
owned_tracks: ownedCount,
|
||||||
|
total_tracks: tracks.length,
|
||||||
|
percentage: 100,
|
||||||
|
missing_tracks: 0
|
||||||
|
};
|
||||||
|
const completionText = sourceCard.querySelector('.completion-text');
|
||||||
|
if (completionText) {
|
||||||
|
completionText.textContent = `Complete (${ownedCount})`;
|
||||||
|
completionText.className = 'completion-text complete';
|
||||||
|
completionText.title = '';
|
||||||
|
}
|
||||||
|
const completionFill = sourceCard.querySelector('.completion-fill');
|
||||||
|
if (completionFill) {
|
||||||
|
completionFill.style.width = '100%';
|
||||||
|
completionFill.classList.remove('partial');
|
||||||
|
completionFill.classList.add('complete');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Could not load track ownership:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Close the Add to Wishlist modal
|
* Close the Add to Wishlist modal
|
||||||
*/
|
*/
|
||||||
|
|
@ -26012,11 +26109,15 @@ function createReleaseCard(release) {
|
||||||
// Use the actual album type from release data
|
// Use the actual album type from release data
|
||||||
const albumType = rel.album_type || rel.type || 'album';
|
const albumType = rel.album_type || rel.type || 'album';
|
||||||
|
|
||||||
// Open the Add to Wishlist modal
|
// Open the Add to Wishlist modal immediately (no waiting for ownership check)
|
||||||
// Note: openAddToWishlistModal has its own loading overlay
|
|
||||||
hideLoadingOverlay();
|
hideLoadingOverlay();
|
||||||
await openAddToWishlistModal(albumData, currentArtist, data.tracks, albumType);
|
await openAddToWishlistModal(albumData, currentArtist, data.tracks, albumType);
|
||||||
|
|
||||||
|
// Lazy-load per-track ownership for partial albums (non-blocking)
|
||||||
|
if (rel.track_completion && typeof rel.track_completion === 'object' && rel.track_completion.missing_tracks > 0) {
|
||||||
|
lazyLoadTrackOwnership(currentArtist.name, data.tracks, card);
|
||||||
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
hideLoadingOverlay();
|
hideLoadingOverlay();
|
||||||
console.error('❌ Error handling release click:', error);
|
console.error('❌ Error handling release click:', error);
|
||||||
|
|
@ -26150,15 +26251,20 @@ function updateLibraryReleaseCard(data) {
|
||||||
card.classList.add('missing');
|
card.classList.add('missing');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If backend says "completed" (>=90%), trust it — Spotify metadata track counts
|
||||||
|
// can be wrong (e.g. total_tracks=15 but API only returns 14 actual tracks)
|
||||||
|
const isComplete = data.status === 'completed';
|
||||||
|
const effectiveMissing = isComplete ? 0 : (data.expected_tracks - data.owned_tracks);
|
||||||
|
|
||||||
// Update the mutable release data on the card
|
// Update the mutable release data on the card
|
||||||
if (card._releaseData) {
|
if (card._releaseData) {
|
||||||
card._releaseData.owned = isOwned;
|
card._releaseData.owned = isOwned;
|
||||||
if (isOwned && data.expected_tracks > 0) {
|
if (isOwned && data.expected_tracks > 0) {
|
||||||
card._releaseData.track_completion = {
|
card._releaseData.track_completion = {
|
||||||
owned_tracks: data.owned_tracks,
|
owned_tracks: data.owned_tracks,
|
||||||
total_tracks: data.expected_tracks,
|
total_tracks: isComplete ? data.owned_tracks : data.expected_tracks,
|
||||||
percentage: data.completion_percentage,
|
percentage: isComplete ? 100 : data.completion_percentage,
|
||||||
missing_tracks: data.expected_tracks - data.owned_tracks
|
missing_tracks: effectiveMissing
|
||||||
};
|
};
|
||||||
} else if (isOwned) {
|
} else if (isOwned) {
|
||||||
card._releaseData.track_completion = {
|
card._releaseData.track_completion = {
|
||||||
|
|
@ -26177,14 +26283,13 @@ function updateLibraryReleaseCard(data) {
|
||||||
if (completionText) {
|
if (completionText) {
|
||||||
completionText.classList.remove('checking', 'complete', 'partial', 'missing');
|
completionText.classList.remove('checking', 'complete', 'partial', 'missing');
|
||||||
if (isOwned) {
|
if (isOwned) {
|
||||||
const missing = data.expected_tracks - data.owned_tracks;
|
if (effectiveMissing <= 0) {
|
||||||
if (missing <= 0) {
|
|
||||||
completionText.textContent = `Complete (${data.owned_tracks})`;
|
completionText.textContent = `Complete (${data.owned_tracks})`;
|
||||||
completionText.className = 'completion-text complete';
|
completionText.className = 'completion-text complete';
|
||||||
} else {
|
} else {
|
||||||
completionText.textContent = `${data.owned_tracks}/${data.expected_tracks} tracks`;
|
completionText.textContent = `${data.owned_tracks}/${data.expected_tracks} tracks`;
|
||||||
completionText.className = 'completion-text partial';
|
completionText.className = 'completion-text partial';
|
||||||
completionText.title = `Missing ${missing} track${missing !== 1 ? 's' : ''}`;
|
completionText.title = `Missing ${effectiveMissing} track${effectiveMissing !== 1 ? 's' : ''}`;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
completionText.textContent = 'Missing';
|
completionText.textContent = 'Missing';
|
||||||
|
|
@ -26197,10 +26302,9 @@ function updateLibraryReleaseCard(data) {
|
||||||
if (completionFill) {
|
if (completionFill) {
|
||||||
completionFill.classList.remove('checking', 'complete', 'partial', 'missing');
|
completionFill.classList.remove('checking', 'complete', 'partial', 'missing');
|
||||||
if (isOwned) {
|
if (isOwned) {
|
||||||
const pct = data.completion_percentage || 100;
|
const pct = isComplete ? 100 : (data.completion_percentage || 100);
|
||||||
completionFill.style.width = `${pct}%`;
|
completionFill.style.width = `${pct}%`;
|
||||||
const missing = data.expected_tracks - data.owned_tracks;
|
completionFill.classList.add(effectiveMissing <= 0 ? 'complete' : 'partial');
|
||||||
completionFill.classList.add(missing <= 0 ? 'complete' : 'partial');
|
|
||||||
} else {
|
} else {
|
||||||
completionFill.style.width = '0%';
|
completionFill.style.width = '0%';
|
||||||
completionFill.classList.add('missing');
|
completionFill.classList.add('missing');
|
||||||
|
|
|
||||||
|
|
@ -13946,6 +13946,35 @@ body {
|
||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Track ownership indicators */
|
||||||
|
.wishlist-track-item.owned {
|
||||||
|
opacity: 0.5;
|
||||||
|
border-left: 3px solid rgba(29, 185, 84, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wishlist-track-item.owned:hover {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wishlist-track-item.missing {
|
||||||
|
border-left: 3px solid rgba(255, 152, 0, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wishlist-track-badge {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 50%;
|
||||||
|
font-size: 12px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wishlist-track-badge.owned {
|
||||||
|
color: rgba(29, 185, 84, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
/* Modal Footer */
|
/* Modal Footer */
|
||||||
.add-to-wishlist-modal-footer {
|
.add-to-wishlist-modal-footer {
|
||||||
background: linear-gradient(135deg,
|
background: linear-gradient(135deg,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue