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.
This commit is contained in:
parent
8c145c29cd
commit
e14a317a56
3 changed files with 229 additions and 12 deletions
|
|
@ -13054,6 +13054,35 @@ def remove_album_from_wishlist():
|
||||||
logger.error(f"Error removing album from wishlist: {e}")
|
logger.error(f"Error removing album from wishlist: {e}")
|
||||||
return jsonify({"success": False, "error": str(e)}), 500
|
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'])
|
@app.route('/api/add-album-to-wishlist', methods=['POST'])
|
||||||
def add_album_track_to_wishlist():
|
def add_album_track_to_wishlist():
|
||||||
"""Endpoint to add a single track from an album to the 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">
|
<div class="wishlist-category-header">
|
||||||
<button class="wishlist-back-btn" onclick="backToCategories()">← Back</button>
|
<button class="wishlist-back-btn" onclick="backToCategories()">← Back</button>
|
||||||
<span id="wishlist-category-name" class="wishlist-category-name"></span>
|
<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>
|
||||||
<div id="wishlist-tracks-list" class="playlist-tracks-scroll">
|
<div id="wishlist-tracks-list" class="playlist-tracks-scroll">
|
||||||
<div class="loading-indicator">Loading tracks...</div>
|
<div class="loading-indicator">Loading tracks...</div>
|
||||||
|
|
@ -7312,6 +7320,12 @@ async function selectWishlistCategory(category) {
|
||||||
|
|
||||||
const tracksListHTML = albumData.tracks.map(track => `
|
const tracksListHTML = albumData.tracks.map(track => `
|
||||||
<div class="wishlist-album-track wishlist-track-item">
|
<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>
|
<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 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 += `
|
albumsHTML += `
|
||||||
<div class="wishlist-album-card">
|
<div class="wishlist-album-card">
|
||||||
<div class="wishlist-album-header" onclick="toggleAlbumTracks('${albumId}')">
|
<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-image" style="${albumImageStyle}">${albumImageContent}</div>
|
||||||
<div class="wishlist-album-info">
|
<div class="wishlist-album-info">
|
||||||
<div class="wishlist-album-name">${albumData.albumName}</div>
|
<div class="wishlist-album-name">${albumData.albumName}</div>
|
||||||
|
|
@ -7389,6 +7409,12 @@ async function selectWishlistCategory(category) {
|
||||||
|
|
||||||
tracksHTML += `
|
tracksHTML += `
|
||||||
<div class="playlist-track-item-with-image wishlist-track-item">
|
<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-image" style="background-image: url('${albumImage}')"></div>
|
||||||
<div class="playlist-track-info">
|
<div class="playlist-track-info">
|
||||||
<div class="playlist-track-name">${trackName}</div>
|
<div class="playlist-track-name">${trackName}</div>
|
||||||
|
|
@ -7414,10 +7440,12 @@ function backToCategories() {
|
||||||
const categoryTracksSection = document.getElementById('wishlist-category-tracks');
|
const categoryTracksSection = document.getElementById('wishlist-category-tracks');
|
||||||
const categoryGrid = document.querySelector('.wishlist-category-grid');
|
const categoryGrid = document.querySelector('.wishlist-category-grid');
|
||||||
const downloadBtn = document.getElementById('wishlist-download-btn');
|
const downloadBtn = document.getElementById('wishlist-download-btn');
|
||||||
|
const batchBar = document.getElementById('wishlist-batch-bar');
|
||||||
|
|
||||||
categoryTracksSection.style.display = 'none';
|
categoryTracksSection.style.display = 'none';
|
||||||
categoryGrid.style.display = 'grid';
|
categoryGrid.style.display = 'grid';
|
||||||
downloadBtn.style.display = 'none';
|
downloadBtn.style.display = 'none';
|
||||||
|
if (batchBar) batchBar.style.display = 'none';
|
||||||
window.selectedWishlistCategory = null;
|
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 = '⚠️') {
|
function showConfirmationModal(title, message, icon = '⚠️') {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
// Create modal if it doesn't exist
|
// Create modal if it doesn't exist
|
||||||
|
|
|
||||||
|
|
@ -8437,8 +8437,9 @@ body {
|
||||||
transform: scale(1.05);
|
transform: scale(1.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Watchlist Batch Action Bar */
|
/* Watchlist & Wishlist Batch Action Bar */
|
||||||
.watchlist-batch-bar {
|
.watchlist-batch-bar,
|
||||||
|
.wishlist-batch-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
|
@ -8462,24 +8463,28 @@ body {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.watchlist-batch-count {
|
.watchlist-batch-count,
|
||||||
|
.wishlist-batch-count {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: rgba(255, 255, 255, 0.8);
|
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;
|
border-color: rgba(255, 59, 48, 0.4) !important;
|
||||||
color: #ff6b6b !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;
|
background: rgba(255, 59, 48, 0.2) !important;
|
||||||
border-color: rgba(255, 59, 48, 0.6) !important;
|
border-color: rgba(255, 59, 48, 0.6) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Watchlist Checkbox */
|
/* Watchlist & Wishlist Checkbox */
|
||||||
.watchlist-checkbox-wrapper {
|
.watchlist-checkbox-wrapper,
|
||||||
|
.wishlist-checkbox-wrapper {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
@ -8489,14 +8494,16 @@ body {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.watchlist-checkbox-wrapper input[type="checkbox"] {
|
.watchlist-checkbox-wrapper input[type="checkbox"],
|
||||||
|
.wishlist-checkbox-wrapper input[type="checkbox"] {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
width: 0;
|
width: 0;
|
||||||
height: 0;
|
height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.watchlist-checkbox-custom {
|
.watchlist-checkbox-custom,
|
||||||
|
.wishlist-checkbox-custom {
|
||||||
width: 20px;
|
width: 20px;
|
||||||
height: 20px;
|
height: 20px;
|
||||||
border: 2px solid rgba(255, 255, 255, 0.2);
|
border: 2px solid rgba(255, 255, 255, 0.2);
|
||||||
|
|
@ -8508,17 +8515,20 @@ body {
|
||||||
justify-content: center;
|
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);
|
border-color: rgba(255, 255, 255, 0.4);
|
||||||
background: rgba(255, 255, 255, 0.06);
|
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);
|
background: rgba(29, 185, 84, 0.3);
|
||||||
border-color: #1db954;
|
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: '✓';
|
content: '✓';
|
||||||
color: #1db954;
|
color: #1db954;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
|
|
@ -8784,6 +8794,30 @@ body {
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #ffffff;
|
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-indicator {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue