Add wishlist batch remove API and UI

Introduce batch removal support for wishlist tracks. Adds a new POST endpoint /api/wishlist/remove-batch that validates input, removes multiple tracks via the wishlist service, logs the result and returns a removed count. Updates the frontend (webui/static/script.js) to provide per-track and per-album checkboxes, a Select All button, a batch action bar with selection count and a Remove Selected action (with confirmation), and logic to refresh the view and wishlist count after removal. Styles (webui/static/style.css) are extended to support unified watchlist/wishlist batch bars, checkbox styling, and a Select All button. Preserves existing single-item removal behavior.
pull/165/head
Broque Thomas 3 months ago
parent 8c145c29cd
commit e14a317a56

@ -13054,6 +13054,35 @@ def remove_album_from_wishlist():
logger.error(f"Error removing album from wishlist: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/wishlist/remove-batch', methods=['POST'])
def remove_batch_from_wishlist():
"""Endpoint to remove multiple tracks from the wishlist."""
try:
from core.wishlist_service import get_wishlist_service
data = request.get_json()
spotify_track_ids = data.get('spotify_track_ids', [])
if not spotify_track_ids or not isinstance(spotify_track_ids, list):
return jsonify({"success": False, "error": "Missing or invalid spotify_track_ids"}), 400
wishlist_service = get_wishlist_service()
removed = 0
for track_id in spotify_track_ids:
if wishlist_service.remove_track_from_wishlist(track_id):
removed += 1
logger.info(f"Batch removed {removed} track(s) from wishlist")
return jsonify({
"success": True,
"removed": removed,
"message": f"Removed {removed} track{'s' if removed != 1 else ''} from wishlist"
})
except Exception as e:
logger.error(f"Error batch removing 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."""

@ -6995,6 +6995,14 @@ async function openWishlistOverviewModal() {
<div class="wishlist-category-header">
<button class="wishlist-back-btn" onclick="backToCategories()"> Back</button>
<span id="wishlist-category-name" class="wishlist-category-name"></span>
<button class="wishlist-select-all-btn" id="wishlist-select-all-btn" onclick="toggleWishlistSelectAll()">Select All</button>
</div>
<div class="wishlist-batch-bar" id="wishlist-batch-bar" style="display: none;">
<span class="wishlist-batch-count" id="wishlist-batch-count">0 selected</span>
<button class="playlist-modal-btn playlist-modal-btn-secondary wishlist-batch-remove-btn"
onclick="batchRemoveFromWishlist()">
Remove Selected
</button>
</div>
<div id="wishlist-tracks-list" class="playlist-tracks-scroll">
<div class="loading-indicator">Loading tracks...</div>
@ -7312,6 +7320,12 @@ async function selectWishlistCategory(category) {
const tracksListHTML = albumData.tracks.map(track => `
<div class="wishlist-album-track wishlist-track-item">
<label class="wishlist-checkbox-wrapper" onclick="event.stopPropagation();">
<input type="checkbox" class="wishlist-select-cb"
data-track-id="${track.spotifyTrackId}"
onchange="updateWishlistBatchBar()">
<span class="wishlist-checkbox-custom"></span>
</label>
<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">
🗑
@ -7328,6 +7342,12 @@ async function selectWishlistCategory(category) {
albumsHTML += `
<div class="wishlist-album-card">
<div class="wishlist-album-header" onclick="toggleAlbumTracks('${albumId}')">
<label class="wishlist-checkbox-wrapper" onclick="event.stopPropagation();">
<input type="checkbox" class="wishlist-album-select-all-cb"
data-album-id="${albumId}"
onchange="toggleWishlistAlbumSelection('${albumId}', this.checked)">
<span class="wishlist-checkbox-custom"></span>
</label>
<div class="wishlist-album-image" style="${albumImageStyle}">${albumImageContent}</div>
<div class="wishlist-album-info">
<div class="wishlist-album-name">${albumData.albumName}</div>
@ -7389,6 +7409,12 @@ async function selectWishlistCategory(category) {
tracksHTML += `
<div class="playlist-track-item-with-image wishlist-track-item">
<label class="wishlist-checkbox-wrapper" onclick="event.stopPropagation();">
<input type="checkbox" class="wishlist-select-cb"
data-track-id="${spotifyTrackId}"
onchange="updateWishlistBatchBar()">
<span class="wishlist-checkbox-custom"></span>
</label>
<div class="playlist-track-image" style="background-image: url('${albumImage}')"></div>
<div class="playlist-track-info">
<div class="playlist-track-name">${trackName}</div>
@ -7414,10 +7440,12 @@ function backToCategories() {
const categoryTracksSection = document.getElementById('wishlist-category-tracks');
const categoryGrid = document.querySelector('.wishlist-category-grid');
const downloadBtn = document.getElementById('wishlist-download-btn');
const batchBar = document.getElementById('wishlist-batch-bar');
categoryTracksSection.style.display = 'none';
categoryGrid.style.display = 'grid';
downloadBtn.style.display = 'none';
if (batchBar) batchBar.style.display = 'none';
window.selectedWishlistCategory = null;
}
@ -7434,6 +7462,132 @@ function toggleAlbumTracks(albumId) {
}
}
/**
* Get all checked wishlist track checkboxes
*/
function getCheckedWishlistTracks() {
return Array.from(document.querySelectorAll('.wishlist-select-cb:checked'));
}
/**
* Toggle select all / deselect all tracks in the current wishlist category
*/
function toggleWishlistSelectAll() {
const allCheckboxes = document.querySelectorAll('.wishlist-select-cb');
const albumCheckboxes = document.querySelectorAll('.wishlist-album-select-all-cb');
const btn = document.getElementById('wishlist-select-all-btn');
const allChecked = allCheckboxes.length > 0 && Array.from(allCheckboxes).every(cb => cb.checked);
const newState = !allChecked;
allCheckboxes.forEach(cb => { cb.checked = newState; });
albumCheckboxes.forEach(cb => { cb.checked = newState; });
// Expand all albums when selecting all
if (newState) {
document.querySelectorAll('.wishlist-album-tracks').forEach(el => {
el.style.display = 'block';
});
document.querySelectorAll('[id^="expand-icon-"]').forEach(icon => {
icon.textContent = '▲';
});
}
if (btn) btn.textContent = newState ? 'Deselect All' : 'Select All';
updateWishlistBatchBar();
}
/**
* Update the wishlist batch action bar based on checkbox selection
*/
function updateWishlistBatchBar() {
const checked = getCheckedWishlistTracks();
const bar = document.getElementById('wishlist-batch-bar');
const countEl = document.getElementById('wishlist-batch-count');
if (!bar || !countEl) return;
if (checked.length > 0) {
bar.style.display = 'flex';
countEl.textContent = `${checked.length} selected`;
} else {
bar.style.display = 'none';
}
// Sync the Select All button text
const btn = document.getElementById('wishlist-select-all-btn');
if (btn) {
const allCheckboxes = document.querySelectorAll('.wishlist-select-cb');
const allChecked = allCheckboxes.length > 0 && Array.from(allCheckboxes).every(cb => cb.checked);
btn.textContent = allChecked ? 'Deselect All' : 'Select All';
}
}
/**
* Toggle all track checkboxes within an album when album header checkbox is clicked
*/
function toggleWishlistAlbumSelection(albumId, checked) {
const tracksContainer = document.getElementById(`tracks-${albumId}`);
if (tracksContainer) {
// Expand the album tracks if selecting
if (checked) {
tracksContainer.style.display = 'block';
const expandIcon = document.getElementById(`expand-icon-${albumId}`);
if (expandIcon) expandIcon.textContent = '▲';
}
tracksContainer.querySelectorAll('.wishlist-select-cb').forEach(cb => {
cb.checked = checked;
});
}
updateWishlistBatchBar();
}
/**
* Batch remove selected tracks from wishlist
*/
async function batchRemoveFromWishlist() {
const checked = getCheckedWishlistTracks();
if (checked.length === 0) return;
const count = checked.length;
const confirmed = await showConfirmationModal(
'Remove Tracks',
`Remove ${count} track${count !== 1 ? 's' : ''} from your wishlist?`,
'🗑️'
);
if (!confirmed) return;
const trackIds = checked.map(cb => cb.getAttribute('data-track-id'));
try {
const response = await fetch('/api/wishlist/remove-batch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ spotify_track_ids: trackIds })
});
const data = await response.json();
if (data.success) {
showToast(`Removed ${data.removed} 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 tracks: ${data.error}`, 'error');
}
} catch (error) {
console.error('Error batch removing from wishlist:', error);
showToast('Failed to remove tracks from wishlist', 'error');
}
}
function showConfirmationModal(title, message, icon = '⚠️') {
return new Promise((resolve) => {
// Create modal if it doesn't exist

@ -8437,8 +8437,9 @@ body {
transform: scale(1.05);
}
/* Watchlist Batch Action Bar */
.watchlist-batch-bar {
/* Watchlist & Wishlist Batch Action Bar */
.watchlist-batch-bar,
.wishlist-batch-bar {
display: flex;
align-items: center;
justify-content: space-between;
@ -8462,24 +8463,28 @@ body {
}
}
.watchlist-batch-count {
.watchlist-batch-count,
.wishlist-batch-count {
font-size: 13px;
font-weight: 600;
color: rgba(255, 255, 255, 0.8);
}
.watchlist-batch-remove-btn {
.watchlist-batch-remove-btn,
.wishlist-batch-remove-btn {
border-color: rgba(255, 59, 48, 0.4) !important;
color: #ff6b6b !important;
}
.watchlist-batch-remove-btn:hover {
.watchlist-batch-remove-btn:hover,
.wishlist-batch-remove-btn:hover {
background: rgba(255, 59, 48, 0.2) !important;
border-color: rgba(255, 59, 48, 0.6) !important;
}
/* Watchlist Checkbox */
.watchlist-checkbox-wrapper {
/* Watchlist & Wishlist Checkbox */
.watchlist-checkbox-wrapper,
.wishlist-checkbox-wrapper {
position: relative;
display: flex;
align-items: center;
@ -8489,14 +8494,16 @@ body {
cursor: pointer;
}
.watchlist-checkbox-wrapper input[type="checkbox"] {
.watchlist-checkbox-wrapper input[type="checkbox"],
.wishlist-checkbox-wrapper input[type="checkbox"] {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
.watchlist-checkbox-custom {
.watchlist-checkbox-custom,
.wishlist-checkbox-custom {
width: 20px;
height: 20px;
border: 2px solid rgba(255, 255, 255, 0.2);
@ -8508,17 +8515,20 @@ body {
justify-content: center;
}
.watchlist-checkbox-wrapper:hover .watchlist-checkbox-custom {
.watchlist-checkbox-wrapper:hover .watchlist-checkbox-custom,
.wishlist-checkbox-wrapper:hover .wishlist-checkbox-custom {
border-color: rgba(255, 255, 255, 0.4);
background: rgba(255, 255, 255, 0.06);
}
.watchlist-checkbox-wrapper input:checked+.watchlist-checkbox-custom {
.watchlist-checkbox-wrapper input:checked+.watchlist-checkbox-custom,
.wishlist-checkbox-wrapper input:checked+.wishlist-checkbox-custom {
background: rgba(29, 185, 84, 0.3);
border-color: #1db954;
}
.watchlist-checkbox-wrapper input:checked+.watchlist-checkbox-custom::after {
.watchlist-checkbox-wrapper input:checked+.watchlist-checkbox-custom::after,
.wishlist-checkbox-wrapper input:checked+.wishlist-checkbox-custom::after {
content: '✓';
color: #1db954;
font-size: 13px;
@ -8784,6 +8794,30 @@ body {
font-size: 18px;
font-weight: 600;
color: #ffffff;
flex: 1;
}
.wishlist-select-all-btn {
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.15);
color: #e0e0e0;
padding: 6px 14px;
border-radius: 8px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
flex-shrink: 0;
}
.wishlist-select-all-btn:hover {
background: rgba(29, 185, 84, 0.15);
border-color: rgba(29, 185, 84, 0.4);
color: #1db954;
}
.wishlist-batch-bar {
margin: 0 0 12px;
}
.loading-indicator {

Loading…
Cancel
Save