Add wishlist track and album removal with confirmation

Introduces backend API endpoints to remove individual tracks or all tracks from an album in the wishlist. Updates the frontend to display delete buttons for tracks and albums, with confirmation modals before removal, and styles these new UI elements for clarity and usability.
This commit is contained in:
Broque Thomas 2025-12-08 11:11:08 -08:00
parent b42d0252a1
commit 25a8117733
3 changed files with 457 additions and 5 deletions

View file

@ -8668,6 +8668,96 @@ def cleanup_wishlist():
traceback.print_exc()
return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/wishlist/remove-track', methods=['POST'])
def remove_track_from_wishlist():
"""Endpoint to remove a single track from the wishlist."""
try:
from core.wishlist_service import get_wishlist_service
data = request.get_json()
spotify_track_id = data.get('spotify_track_id')
if not spotify_track_id:
return jsonify({"success": False, "error": "No spotify_track_id provided"}), 400
wishlist_service = get_wishlist_service()
success = wishlist_service.remove_track_from_wishlist(spotify_track_id)
if success:
logger.info(f"Successfully removed track from wishlist: {spotify_track_id}")
return jsonify({"success": True, "message": "Track removed from wishlist"})
else:
logger.warning(f"Failed to remove track from wishlist: {spotify_track_id}")
return jsonify({"success": False, "error": "Track not found in wishlist"}), 404
except Exception as e:
logger.error(f"Error removing track from wishlist: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/wishlist/remove-album', methods=['POST'])
def remove_album_from_wishlist():
"""Endpoint to remove all tracks from an album from the wishlist."""
try:
from core.wishlist_service import get_wishlist_service
import json
data = request.get_json()
album_id = data.get('album_id')
if not album_id:
return jsonify({"success": False, "error": "No album_id provided"}), 400
wishlist_service = get_wishlist_service()
all_tracks = wishlist_service.get_wishlist_tracks_for_download()
# Find all tracks that belong to this album
tracks_to_remove = []
for track in all_tracks:
spotify_data = track.get('spotify_data', {})
if isinstance(spotify_data, str):
try:
spotify_data = json.loads(spotify_data)
except:
spotify_data = {}
# Get album ID - use spotify ID if available, otherwise create custom ID
track_album_id = spotify_data.get('album', {}).get('id')
if not track_album_id:
# Create custom ID matching frontend logic: album_name_artist_name (lowercase, consecutive spaces -> single underscore)
album_name = spotify_data.get('album', {}).get('name', 'Unknown Album')
artist_name = spotify_data.get('artists', [{}])[0].get('name', 'Unknown Artist') if spotify_data.get('artists') else 'Unknown Artist'
custom_id = f"{album_name}_{artist_name}"
# Match frontend regex: /\s+/g -> replace consecutive whitespace with single underscore
track_album_id = re.sub(r'\s+', '_', custom_id).lower()
# Match by album ID
if track_album_id == album_id:
spotify_track_id = track.get('spotify_track_id') or track.get('id')
if spotify_track_id:
tracks_to_remove.append(spotify_track_id)
# Remove all matching tracks
removed_count = 0
for spotify_track_id in tracks_to_remove:
if wishlist_service.remove_track_from_wishlist(spotify_track_id):
removed_count += 1
if removed_count > 0:
logger.info(f"Successfully removed {removed_count} tracks from album {album_id}")
return jsonify({
"success": True,
"message": f"Removed {removed_count} track(s) from wishlist",
"removed_count": removed_count
})
else:
logger.warning(f"No tracks found for album {album_id}")
return jsonify({"success": False, "error": "No tracks found for this album"}), 404
except Exception as e:
logger.error(f"Error removing album from wishlist: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/add-album-to-wishlist', methods=['POST'])
def add_album_track_to_wishlist():
"""Endpoint to add a single track from an album to the wishlist."""

View file

@ -5203,10 +5203,13 @@ async function selectWishlistCategory(category) {
};
}
const spotifyTrackId = track.spotify_track_id || track.id || '';
albumGroups[albumId].tracks.push({
name: track.name || 'Unknown Track',
artistName,
trackNumber: spotifyData?.track_number || 0
trackNumber: spotifyData?.track_number || 0,
spotifyTrackId
});
});
@ -5217,20 +5220,26 @@ async function selectWishlistCategory(category) {
albumData.tracks.sort((a, b) => a.trackNumber - b.trackNumber);
const tracksListHTML = albumData.tracks.map(track => `
<div class="wishlist-album-track">
<div class="wishlist-album-track wishlist-track-item">
<span class="wishlist-album-track-name">${track.name}</span>
<button class="wishlist-delete-btn wishlist-delete-btn-small" onclick="removeTrackFromWishlist('${track.spotifyTrackId}', event)" title="Remove from wishlist">
🗑
</button>
</div>
`).join('');
albumsHTML += `
<div class="wishlist-album-card" onclick="toggleAlbumTracks('${albumId}')">
<div class="wishlist-album-header">
<div class="wishlist-album-card">
<div class="wishlist-album-header" onclick="toggleAlbumTracks('${albumId}')">
<div class="wishlist-album-image" style="background-image: url('${albumData.albumImage}')"></div>
<div class="wishlist-album-info">
<div class="wishlist-album-name">${albumData.albumName}</div>
<div class="wishlist-album-artist">${albumData.artistName}</div>
<div class="wishlist-album-track-count">${albumData.tracks.length} track${albumData.tracks.length !== 1 ? 's' : ''}</div>
</div>
<button class="wishlist-delete-btn wishlist-delete-album-btn" onclick="removeAlbumFromWishlist('${albumId}', event)" title="Remove all tracks from album">
🗑
</button>
<div class="wishlist-album-expand-icon" id="expand-icon-${albumId}"></div>
</div>
<div class="wishlist-album-tracks" id="tracks-${albumId}" style="display: none;">
@ -5279,14 +5288,18 @@ async function selectWishlistCategory(category) {
}
const albumImage = spotifyData?.album?.images?.[0]?.url || '';
const spotifyTrackId = track.spotify_track_id || track.id || '';
tracksHTML += `
<div class="playlist-track-item-with-image">
<div class="playlist-track-item-with-image wishlist-track-item">
<div class="playlist-track-image" style="background-image: url('${albumImage}')"></div>
<div class="playlist-track-info">
<div class="playlist-track-name">${trackName}</div>
<div class="playlist-track-artist">${artistName} ${albumName}</div>
</div>
<button class="wishlist-delete-btn" onclick="removeTrackFromWishlist('${spotifyTrackId}', event)" title="Remove from wishlist">
🗑
</button>
</div>
`;
});
@ -5324,6 +5337,164 @@ function toggleAlbumTracks(albumId) {
}
}
function showConfirmationModal(title, message, icon = '⚠️') {
return new Promise((resolve) => {
// Create modal if it doesn't exist
let modal = document.getElementById('confirmation-modal-overlay');
if (!modal) {
modal = document.createElement('div');
modal.id = 'confirmation-modal-overlay';
modal.className = 'confirmation-modal-overlay';
document.body.appendChild(modal);
}
// Set modal content
modal.innerHTML = `
<div class="confirmation-modal">
<div class="confirmation-modal-icon">${icon}</div>
<div class="confirmation-modal-title">${title}</div>
<div class="confirmation-modal-message">${message}</div>
<div class="confirmation-modal-buttons">
<button class="confirmation-modal-btn confirmation-modal-btn-cancel" id="confirm-cancel">Cancel</button>
<button class="confirmation-modal-btn confirmation-modal-btn-confirm" id="confirm-yes">Yes, Remove</button>
</div>
</div>
`;
// Show modal with animation
setTimeout(() => {
modal.classList.add('show');
}, 10);
// Escape key handler - defined outside so we can remove it
const handleEscape = (e) => {
if (e.key === 'Escape') {
handleCancel();
}
};
// Handle button clicks
const handleCancel = () => {
document.removeEventListener('keydown', handleEscape);
modal.classList.remove('show');
setTimeout(() => {
modal.remove();
}, 200);
resolve(false);
};
const handleConfirm = () => {
document.removeEventListener('keydown', handleEscape);
modal.classList.remove('show');
setTimeout(() => {
modal.remove();
}, 200);
resolve(true);
};
document.getElementById('confirm-cancel').addEventListener('click', handleCancel);
document.getElementById('confirm-yes').addEventListener('click', handleConfirm);
// Close on overlay click
modal.addEventListener('click', (e) => {
if (e.target === modal) {
handleCancel();
}
});
// Add Escape key listener
document.addEventListener('keydown', handleEscape);
});
}
async function removeTrackFromWishlist(spotifyTrackId, event) {
// Stop event propagation to prevent triggering parent click handlers
if (event) {
event.stopPropagation();
}
const confirmed = await showConfirmationModal(
'Remove Track',
'Are you sure you want to remove this track from your wishlist?',
'🗑️'
);
if (!confirmed) {
return;
}
try {
const response = await fetch('/api/wishlist/remove-track', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ spotify_track_id: spotifyTrackId })
});
const data = await response.json();
if (data.success) {
showToast('Track removed from wishlist', 'success');
// Reload the current category to refresh the list
if (window.selectedWishlistCategory) {
await selectWishlistCategory(window.selectedWishlistCategory);
}
// Update wishlist count in sidebar
await updateWishlistCount();
} else {
showToast(`Failed to remove track: ${data.error}`, 'error');
}
} catch (error) {
console.error('Error removing track from wishlist:', error);
showToast('Failed to remove track from wishlist', 'error');
}
}
async function removeAlbumFromWishlist(albumId, event) {
// Stop event propagation to prevent triggering parent click handlers
if (event) {
event.stopPropagation();
}
const confirmed = await showConfirmationModal(
'Remove Album',
'Are you sure you want to remove all tracks from this album from your wishlist?',
'💿'
);
if (!confirmed) {
return;
}
try {
const response = await fetch('/api/wishlist/remove-album', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ album_id: albumId })
});
const data = await response.json();
if (data.success) {
showToast(`Removed ${data.removed_count} track(s) from wishlist`, 'success');
// Reload the current category to refresh the list
if (window.selectedWishlistCategory) {
await selectWishlistCategory(window.selectedWishlistCategory);
}
// Update wishlist count in sidebar
await updateWishlistCount();
} else {
showToast(`Failed to remove album: ${data.error}`, 'error');
}
} catch (error) {
console.error('Error removing album from wishlist:', error);
showToast('Failed to remove album from wishlist', 'error');
}
}
async function downloadSelectedCategory() {
const category = window.selectedWishlistCategory;
if (!category) {

View file

@ -7900,6 +7900,197 @@ body {
flex-shrink: 0;
}
/* Wishlist Delete Buttons */
.wishlist-track-item {
position: relative;
}
.wishlist-delete-btn {
position: absolute;
right: 12px;
top: 50%;
transform: translateY(-50%);
background: rgba(220, 53, 69, 0.9);
border: none;
border-radius: 6px;
padding: 8px 12px;
font-size: 16px;
cursor: pointer;
transition: all 0.2s ease;
opacity: 0;
pointer-events: none;
z-index: 10;
}
.wishlist-track-item:hover .wishlist-delete-btn {
opacity: 1;
pointer-events: all;
}
.wishlist-delete-btn:hover {
background: rgba(220, 53, 69, 1);
transform: translateY(-50%) scale(1.1);
}
.wishlist-delete-btn:active {
transform: translateY(-50%) scale(0.95);
}
/* Smaller delete button for tracks within albums */
.wishlist-delete-btn-small {
padding: 6px 10px;
font-size: 14px;
}
/* Album header delete button */
.wishlist-album-header {
position: relative;
}
.wishlist-delete-album-btn {
position: absolute;
right: 40px;
top: 50%;
transform: translateY(-50%);
background: rgba(220, 53, 69, 0.9);
border: none;
border-radius: 6px;
padding: 8px 12px;
font-size: 16px;
cursor: pointer;
transition: all 0.2s ease;
opacity: 0;
pointer-events: none;
z-index: 10;
}
.wishlist-album-card:hover .wishlist-delete-album-btn {
opacity: 1;
pointer-events: all;
}
.wishlist-delete-album-btn:hover {
background: rgba(220, 53, 69, 1);
transform: translateY(-50%) scale(1.1);
}
.wishlist-delete-album-btn:active {
transform: translateY(-50%) scale(0.95);
}
/* Ensure album track items have proper positioning for delete button */
.wishlist-album-track.wishlist-track-item {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
}
.wishlist-album-track.wishlist-track-item .wishlist-album-track-name {
flex: 1;
}
/* Confirmation Modal - Higher z-index to show above wishlist modal */
.confirmation-modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(8px);
z-index: 12000;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
pointer-events: none;
transition: opacity 0.2s ease;
}
.confirmation-modal-overlay.show {
opacity: 1;
pointer-events: all;
}
.confirmation-modal {
background: #1a1a1a;
border: 1px solid rgba(29, 185, 84, 0.3);
border-radius: 12px;
padding: 32px;
max-width: 400px;
width: 90%;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6);
transform: scale(0.9);
transition: transform 0.2s ease;
}
.confirmation-modal-overlay.show .confirmation-modal {
transform: scale(1);
}
.confirmation-modal-icon {
font-size: 48px;
text-align: center;
margin-bottom: 16px;
}
.confirmation-modal-title {
font-size: 20px;
font-weight: 600;
color: #ffffff;
text-align: center;
margin-bottom: 12px;
}
.confirmation-modal-message {
font-size: 14px;
color: #b3b3b3;
text-align: center;
margin-bottom: 24px;
line-height: 1.5;
}
.confirmation-modal-buttons {
display: flex;
gap: 12px;
justify-content: center;
}
.confirmation-modal-btn {
padding: 12px 32px;
border-radius: 8px;
border: none;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
min-width: 100px;
}
.confirmation-modal-btn-cancel {
background: rgba(255, 255, 255, 0.1);
color: #ffffff;
}
.confirmation-modal-btn-cancel:hover {
background: rgba(255, 255, 255, 0.15);
}
.confirmation-modal-btn-confirm {
background: #dc3545;
color: #ffffff;
}
.confirmation-modal-btn-confirm:hover {
background: #c82333;
transform: scale(1.05);
}
.confirmation-modal-btn:active {
transform: scale(0.95);
}
/* Mobile Responsive */
@media (max-width: 768px) {
.wishlist-category-grid {