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:
parent
b42d0252a1
commit
25a8117733
3 changed files with 457 additions and 5 deletions
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue