feat: add song selection toolbar and popular songs section
- Enable bulk song selection with custom checkboxes and select-all header row - Add "Most Popular" section (scored by likes + 2×favorites) alongside Trending - Improve horizontal scrolling for liked strips with grab/grabbing cursor and drag-to-scroll logic - Introduce neon divider (`hr.hr-neon`) for visual separation between sections - Update CSS grid layout for selectable song lists to accommodate checkboxes
This commit is contained in:
parent
dc64286b15
commit
3586e1da9a
|
|
@ -553,7 +553,9 @@ a.detail-creator-card:hover {
|
||||||
display: flex; gap: 0.75rem; overflow-x: auto;
|
display: flex; gap: 0.75rem; overflow-x: auto;
|
||||||
padding-bottom: 0.4rem; scrollbar-width: thin;
|
padding-bottom: 0.4rem; scrollbar-width: thin;
|
||||||
scrollbar-color: var(--border) transparent;
|
scrollbar-color: var(--border) transparent;
|
||||||
|
cursor: grab;
|
||||||
}
|
}
|
||||||
|
.liked-strip-scroll:active { cursor: grabbing; }
|
||||||
.liked-card {
|
.liked-card {
|
||||||
flex: 0 0 auto; display: flex; align-items: center; gap: 0.55rem;
|
flex: 0 0 auto; display: flex; align-items: center; gap: 0.55rem;
|
||||||
background: var(--bg-panel); border: 1px solid var(--border);
|
background: var(--bg-panel); border: 1px solid var(--border);
|
||||||
|
|
@ -1396,6 +1398,15 @@ a.detail-creator-card:hover {
|
||||||
background: linear-gradient(to right, transparent, var(--neon-cyan), transparent);
|
background: linear-gradient(to right, transparent, var(--neon-cyan), transparent);
|
||||||
box-shadow: 0 0 8px var(--neon-cyan), 0 0 20px rgba(0, 255, 255, 0.2);
|
box-shadow: 0 0 8px var(--neon-cyan), 0 0 20px rgba(0, 255, 255, 0.2);
|
||||||
}
|
}
|
||||||
|
/* Standalone neon divider — matches profile section borders */
|
||||||
|
hr.hr-neon {
|
||||||
|
border: none;
|
||||||
|
height: 1px;
|
||||||
|
background: linear-gradient(to right, transparent, var(--neon-cyan), transparent);
|
||||||
|
box-shadow: 0 0 8px var(--neon-cyan), 0 0 20px rgba(0, 255, 255, 0.2);
|
||||||
|
margin: 2rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* No line/extra space before the first section */
|
/* No line/extra space before the first section */
|
||||||
.profile-header + section {
|
.profile-header + section {
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
|
|
@ -1671,7 +1682,7 @@ a.detail-creator-card:hover {
|
||||||
box-shadow: 0 0 6px rgba(143, 91, 255, 0.3);
|
box-shadow: 0 0 6px rgba(143, 91, 255, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
#bulk-toolbar {
|
#bulk-toolbar, #song-sel-toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
|
|
@ -1679,6 +1690,52 @@ a.detail-creator-card:hover {
|
||||||
margin-bottom: 0.4rem;
|
margin-bottom: 0.4rem;
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Select-all header row */
|
||||||
|
.song-list-select-all-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.3rem 0.6rem 0.3rem 0.6rem;
|
||||||
|
border-bottom: 1px solid rgba(143, 91, 255, 0.18);
|
||||||
|
}
|
||||||
|
.song-list-select-all-row label { cursor: pointer; user-select: none; }
|
||||||
|
|
||||||
|
/* Song-list checkboxes (selectable mode) */
|
||||||
|
.song-select, #song-select-all {
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
width: 1rem; height: 1rem;
|
||||||
|
border: 1.5px solid var(--neon-violet);
|
||||||
|
border-radius: 3px;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
vertical-align: middle;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: background 0.15s, box-shadow 0.15s;
|
||||||
|
}
|
||||||
|
.song-select:hover, #song-select-all:hover { box-shadow: 0 0 6px rgba(143, 91, 255, 0.4); }
|
||||||
|
.song-select:checked, #song-select-all:checked {
|
||||||
|
background: var(--neon-violet);
|
||||||
|
box-shadow: 0 0 8px rgba(143, 91, 255, 0.6);
|
||||||
|
}
|
||||||
|
.song-select:checked::after, #song-select-all:checked::after {
|
||||||
|
content: '✓';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
#song-select-all:indeterminate {
|
||||||
|
background: rgba(143, 91, 255, 0.35);
|
||||||
|
box-shadow: 0 0 6px rgba(143, 91, 255, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.bulk-dropdown { position: relative; }
|
.bulk-dropdown { position: relative; }
|
||||||
|
|
@ -1945,6 +2002,13 @@ a.detail-creator-card:hover {
|
||||||
align-items: start;
|
align-items: start;
|
||||||
padding: 0.65rem 0;
|
padding: 0.65rem 0;
|
||||||
}
|
}
|
||||||
|
.song-list--selectable .song-row {
|
||||||
|
grid-template-columns: 16px 52px 1fr auto;
|
||||||
|
grid-template-areas:
|
||||||
|
"select cover main play"
|
||||||
|
"select cover social play";
|
||||||
|
}
|
||||||
|
.song-list--selectable .song-row .song-select { grid-area: select; align-self: center; }
|
||||||
.song-row .song-cover { grid-area: cover; align-self: center; width: 52px; height: 52px; }
|
.song-row .song-cover { grid-area: cover; align-self: center; width: 52px; height: 52px; }
|
||||||
.song-row .song-main { grid-area: main; align-self: center; }
|
.song-row .song-main { grid-area: main; align-self: center; }
|
||||||
.song-row .song-social { grid-area: social; align-self: center; }
|
.song-row .song-social { grid-area: social; align-self: center; }
|
||||||
|
|
|
||||||
|
|
@ -69,13 +69,18 @@ router.get('/songs', (req, res) => {
|
||||||
const genres = songs.listGenres();
|
const genres = songs.listGenres();
|
||||||
const uploaders = songs.listUploaders(loggedIn, isVip);
|
const uploaders = songs.listUploaders(loggedIn, isVip);
|
||||||
|
|
||||||
const recentlyLiked = social.getRecentlyLikedSongs(5, loggedIn, isVip).map(songs.toView);
|
const recentlyLiked = social.getRecentlyLikedSongs(20, loggedIn, isVip).map(songs.toView);
|
||||||
social.enrichSongs(recentlyLiked, userId);
|
social.enrichSongs(recentlyLiked, userId);
|
||||||
|
|
||||||
|
const mostPopularRaw = social.getMostPopular(20, loggedIn, isVip).map(songs.toView);
|
||||||
|
social.enrichSongs(mostPopularRaw, userId);
|
||||||
|
|
||||||
|
const userPlaylists = isVip && userId ? playlists.listByUser(userId) : [];
|
||||||
|
|
||||||
res.render('public/songs', {
|
res.render('public/songs', {
|
||||||
title: 'Songs', songs: rows, q, genre, uploadedBy,
|
title: 'Songs', songs: rows, q, genre, uploadedBy,
|
||||||
page, pageSize, total, totalPages: Math.max(1, Math.ceil(total / pageSize)),
|
page, pageSize, total, totalPages: Math.max(1, Math.ceil(total / pageSize)),
|
||||||
genres, uploaders, recentlyLiked,
|
genres, uploaders, recentlyLiked, mostPopular: mostPopularRaw, userPlaylists,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -189,10 +189,52 @@ function getRecentlyLikedSongs(n = 5, loggedIn = false, isVip = false) {
|
||||||
return (isVip ? recentlyLikedVipStmt : loggedIn ? recentlyLikedUserStmt : recentlyLikedGuestStmt).all(n);
|
return (isVip ? recentlyLikedVipStmt : loggedIn ? recentlyLikedUserStmt : recentlyLikedGuestStmt).all(n);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Most popular songs (scored by likes + 2×favorites) ───────────────────────
|
||||||
|
const mostPopularGuestStmt = db.prepare(`
|
||||||
|
SELECT s.*,
|
||||||
|
(COUNT(DISTINCT sl.user_id) + 2 * COUNT(DISTINCT sf.user_id)) AS score
|
||||||
|
FROM songs s
|
||||||
|
LEFT JOIN song_likes sl ON sl.song_id = s.id
|
||||||
|
LEFT JOIN song_favorites sf ON sf.song_id = s.id
|
||||||
|
WHERE s.visibility = 'public'
|
||||||
|
GROUP BY s.id
|
||||||
|
HAVING score > 0
|
||||||
|
ORDER BY score DESC, s.created_at DESC
|
||||||
|
LIMIT ?
|
||||||
|
`);
|
||||||
|
const mostPopularUserStmt = db.prepare(`
|
||||||
|
SELECT s.*,
|
||||||
|
(COUNT(DISTINCT sl.user_id) + 2 * COUNT(DISTINCT sf.user_id)) AS score
|
||||||
|
FROM songs s
|
||||||
|
LEFT JOIN song_likes sl ON sl.song_id = s.id
|
||||||
|
LEFT JOIN song_favorites sf ON sf.song_id = s.id
|
||||||
|
WHERE s.visibility IN ('public', 'logged_in')
|
||||||
|
GROUP BY s.id
|
||||||
|
HAVING score > 0
|
||||||
|
ORDER BY score DESC, s.created_at DESC
|
||||||
|
LIMIT ?
|
||||||
|
`);
|
||||||
|
const mostPopularVipStmt = db.prepare(`
|
||||||
|
SELECT s.*,
|
||||||
|
(COUNT(DISTINCT sl.user_id) + 2 * COUNT(DISTINCT sf.user_id)) AS score
|
||||||
|
FROM songs s
|
||||||
|
LEFT JOIN song_likes sl ON sl.song_id = s.id
|
||||||
|
LEFT JOIN song_favorites sf ON sf.song_id = s.id
|
||||||
|
WHERE s.visibility IN ('public', 'logged_in', 'vip')
|
||||||
|
GROUP BY s.id
|
||||||
|
HAVING score > 0
|
||||||
|
ORDER BY score DESC, s.created_at DESC
|
||||||
|
LIMIT ?
|
||||||
|
`);
|
||||||
|
|
||||||
|
function getMostPopular(n = 20, loggedIn = false, isVip = false) {
|
||||||
|
return (isVip ? mostPopularVipStmt : loggedIn ? mostPopularUserStmt : mostPopularGuestStmt).all(n);
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
toggleSongLike, toggleSongFavorite,
|
toggleSongLike, toggleSongFavorite,
|
||||||
togglePlaylistLike, togglePlaylistFavorite,
|
togglePlaylistLike, togglePlaylistFavorite,
|
||||||
enrichSongs, enrichPlaylists,
|
enrichSongs, enrichPlaylists,
|
||||||
getLikedSongs, getLikedPlaylists,
|
getLikedSongs, getLikedPlaylists,
|
||||||
getRecentlyLikedSongs,
|
getRecentlyLikedSongs, getMostPopular,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,15 @@
|
||||||
<ul class="song-list">
|
<% if (typeof selectable !== 'undefined' && selectable) { %>
|
||||||
|
<div class="song-list-select-all-row">
|
||||||
|
<input type="checkbox" id="song-select-all" aria-label="Select all songs on this page">
|
||||||
|
<label for="song-select-all" class="muted small">Select all</label>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
<ul class="song-list<%= (typeof selectable !== 'undefined' && selectable) ? ' song-list--selectable' : '' %>">
|
||||||
<% songs.forEach((s) => { %>
|
<% songs.forEach((s) => { %>
|
||||||
<li class="song-row" data-song-id="<%= s.id %>">
|
<li class="song-row" data-song-id="<%= s.id %>">
|
||||||
|
<% if (typeof selectable !== 'undefined' && selectable) { %>
|
||||||
|
<input type="checkbox" class="song-select" value="<%= s.id %>" aria-label="Select <%= s.title %>">
|
||||||
|
<% } %>
|
||||||
<% if (s.coverPath) { %>
|
<% if (s.coverPath) { %>
|
||||||
<img src="/media/<%= s.coverPath %>" alt="" class="song-cover" />
|
<img src="/media/<%= s.coverPath %>" alt="" class="song-cover" />
|
||||||
<% } else { %>
|
<% } else { %>
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
<% if (recentlyLiked && recentlyLiked.length > 0) { %>
|
<% if (recentlyLiked && recentlyLiked.length > 0) { %>
|
||||||
<section class="liked-strip">
|
<section class="liked-strip">
|
||||||
<h2 class="liked-strip-heading">Trending</h2>
|
<h2 class="liked-strip-heading">Trending</h2>
|
||||||
<div class="liked-strip-scroll">
|
<div class="liked-strip-scroll" id="trending-scroll">
|
||||||
<% recentlyLiked.forEach((s) => { %>
|
<% recentlyLiked.forEach((s) => { %>
|
||||||
<a href="/songs/<%= s.slug %>" class="liked-card">
|
<a href="/songs/<%= s.slug %>" class="liked-card">
|
||||||
<% if (s.coverPath) { %>
|
<% if (s.coverPath) { %>
|
||||||
|
|
@ -19,8 +19,81 @@
|
||||||
<% }) %>
|
<% }) %>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var el = document.getElementById('trending-scroll');
|
||||||
|
if (!el) return;
|
||||||
|
var dragging = false, startX, scrollLeft;
|
||||||
|
el.addEventListener('mousedown', function (e) {
|
||||||
|
dragging = true;
|
||||||
|
startX = e.pageX - el.offsetLeft;
|
||||||
|
scrollLeft = el.scrollLeft;
|
||||||
|
el.style.cursor = 'grabbing';
|
||||||
|
el.style.userSelect = 'none';
|
||||||
|
});
|
||||||
|
document.addEventListener('mouseup', function () {
|
||||||
|
dragging = false;
|
||||||
|
el.style.cursor = '';
|
||||||
|
el.style.userSelect = '';
|
||||||
|
});
|
||||||
|
document.addEventListener('mousemove', function (e) {
|
||||||
|
if (!dragging) return;
|
||||||
|
e.preventDefault();
|
||||||
|
var x = e.pageX - el.offsetLeft;
|
||||||
|
el.scrollLeft = scrollLeft - (x - startX);
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
<% } %>
|
<% } %>
|
||||||
|
|
||||||
|
<% if (mostPopular && mostPopular.length > 0) { %>
|
||||||
|
<section class="liked-strip">
|
||||||
|
<h2 class="liked-strip-heading">Most Popular</h2>
|
||||||
|
<div class="liked-strip-scroll" id="popular-scroll">
|
||||||
|
<% mostPopular.forEach((s) => { %>
|
||||||
|
<a href="/songs/<%= s.slug %>" class="liked-card">
|
||||||
|
<% if (s.coverPath) { %>
|
||||||
|
<img src="/media/<%= s.coverPath %>" alt="" class="liked-card-cover">
|
||||||
|
<% } else { %>
|
||||||
|
<div class="liked-card-cover liked-card-cover--fallback">♪</div>
|
||||||
|
<% } %>
|
||||||
|
<span class="liked-card-info">
|
||||||
|
<span class="liked-card-title"><%= s.title %></span>
|
||||||
|
<span class="liked-card-artist"><%= s.artist %></span>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
<% }) %>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var el = document.getElementById('popular-scroll');
|
||||||
|
if (!el) return;
|
||||||
|
var dragging = false, startX, scrollLeft;
|
||||||
|
el.addEventListener('mousedown', function (e) {
|
||||||
|
dragging = true;
|
||||||
|
startX = e.pageX - el.offsetLeft;
|
||||||
|
scrollLeft = el.scrollLeft;
|
||||||
|
el.style.cursor = 'grabbing';
|
||||||
|
el.style.userSelect = 'none';
|
||||||
|
});
|
||||||
|
document.addEventListener('mouseup', function () {
|
||||||
|
dragging = false;
|
||||||
|
el.style.cursor = '';
|
||||||
|
el.style.userSelect = '';
|
||||||
|
});
|
||||||
|
document.addEventListener('mousemove', function (e) {
|
||||||
|
if (!dragging) return;
|
||||||
|
e.preventDefault();
|
||||||
|
var x = e.pageX - el.offsetLeft;
|
||||||
|
el.scrollLeft = scrollLeft - (x - startX);
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
<% } %>
|
||||||
|
|
||||||
|
<hr class="hr-neon">
|
||||||
|
|
||||||
<form method="get" action="/songs" class="search-form">
|
<form method="get" action="/songs" class="search-form">
|
||||||
<div class="search-row">
|
<div class="search-row">
|
||||||
<input type="search" name="q" value="<%= q %>" placeholder="Search title, artist, album…" />
|
<input type="search" name="q" value="<%= q %>" placeholder="Search title, artist, album…" />
|
||||||
|
|
@ -54,7 +127,155 @@
|
||||||
<% if (songs.length === 0) { %>
|
<% if (songs.length === 0) { %>
|
||||||
<p class="muted">No songs found.</p>
|
<p class="muted">No songs found.</p>
|
||||||
<% } else { %>
|
<% } else { %>
|
||||||
<%- include('../partials/song_list', { songs }) %>
|
|
||||||
|
<div id="song-sel-toolbar" style="display:none">
|
||||||
|
<span><span id="song-sel-count">0</span> selected</span>
|
||||||
|
<button type="button" id="song-sel-play" class="btn-primary" data-play-playlist="[]" data-nowplaying-meta="{}">▶ Play Selected</button>
|
||||||
|
<button type="button" id="song-sel-shuffle" class="btn-primary" data-shuffle-playlist="[]" data-nowplaying-meta="{}">⇄ Shuffle Selected</button>
|
||||||
|
<% if (user && (user.isVip || user.role === 'admin')) { %>
|
||||||
|
<div class="bulk-dropdown">
|
||||||
|
<button type="button" id="song-sel-add-btn" class="btn-secondary">Add to Playlist ▼</button>
|
||||||
|
<ul class="bulk-menu" id="song-sel-add-menu">
|
||||||
|
<li><a id="song-sel-new-playlist" href="#">+ New Playlist</a></li>
|
||||||
|
<li class="has-submenu">
|
||||||
|
<span>Existing Playlist</span>
|
||||||
|
<ul class="bulk-submenu">
|
||||||
|
<% userPlaylists.forEach(function(pl) { %>
|
||||||
|
<li><a class="song-sel-pl-item" data-playlist-id="<%= pl.id %>" href="#"><%= pl.title %></a></li>
|
||||||
|
<% }) %>
|
||||||
|
<% if (!userPlaylists.length) { %>
|
||||||
|
<li class="bulk-submenu-empty">No playlists yet</li>
|
||||||
|
<% } %>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
<button type="button" id="song-sel-clear" class="linklike muted small">Clear</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%- include('../partials/song_list', { songs, selectable: true }) %>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var toolbar = document.getElementById('song-sel-toolbar');
|
||||||
|
var countEl = document.getElementById('song-sel-count');
|
||||||
|
var playBtn = document.getElementById('song-sel-play');
|
||||||
|
var shuffleBtn = document.getElementById('song-sel-shuffle');
|
||||||
|
var clearBtn = document.getElementById('song-sel-clear');
|
||||||
|
var selectAll = document.getElementById('song-select-all');
|
||||||
|
var addBtn = document.getElementById('song-sel-add-btn');
|
||||||
|
var addMenu = document.getElementById('song-sel-add-menu');
|
||||||
|
var newPlBtn = document.getElementById('song-sel-new-playlist');
|
||||||
|
var csrfToken = (document.querySelector('meta[name="csrf-token"]') || {}).content || '';
|
||||||
|
|
||||||
|
function allBoxes() { return document.querySelectorAll('.song-select'); }
|
||||||
|
function checkedBoxes() { return document.querySelectorAll('.song-select:checked'); }
|
||||||
|
|
||||||
|
function getSongData(cb) {
|
||||||
|
var row = cb.closest('.song-row');
|
||||||
|
var btn = row && row.querySelector('[data-play-song]');
|
||||||
|
if (!btn) return null;
|
||||||
|
try { return JSON.parse(btn.getAttribute('data-play-song')); } catch (e) { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function getIds() { return Array.from(checkedBoxes()).map(function (cb) { return cb.value; }); }
|
||||||
|
|
||||||
|
function updateToolbar() {
|
||||||
|
var checked = Array.from(checkedBoxes());
|
||||||
|
var total = allBoxes().length;
|
||||||
|
toolbar.style.display = checked.length ? '' : 'none';
|
||||||
|
countEl.textContent = checked.length;
|
||||||
|
if (selectAll) {
|
||||||
|
selectAll.indeterminate = checked.length > 0 && checked.length < total;
|
||||||
|
selectAll.checked = checked.length > 0 && checked.length === total;
|
||||||
|
}
|
||||||
|
if (checked.length) {
|
||||||
|
var list = checked.map(getSongData).filter(Boolean);
|
||||||
|
var json = JSON.stringify(list);
|
||||||
|
var npMeta = JSON.stringify({
|
||||||
|
title: checked.length + ' Selected Song' + (checked.length === 1 ? '' : 's'),
|
||||||
|
description: null,
|
||||||
|
cover: list.length === 1 ? (list[0].cover || null) : null,
|
||||||
|
trackCount: list.length,
|
||||||
|
creator: null,
|
||||||
|
});
|
||||||
|
playBtn.setAttribute('data-play-playlist', json);
|
||||||
|
playBtn.setAttribute('data-nowplaying-meta', npMeta);
|
||||||
|
shuffleBtn.setAttribute('data-shuffle-playlist', json);
|
||||||
|
shuffleBtn.setAttribute('data-nowplaying-meta', npMeta);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('change', function (e) {
|
||||||
|
if (e.target.matches('.song-select')) updateToolbar();
|
||||||
|
});
|
||||||
|
|
||||||
|
if (selectAll) {
|
||||||
|
selectAll.addEventListener('change', function () {
|
||||||
|
allBoxes().forEach(function (cb) { cb.checked = selectAll.checked; });
|
||||||
|
updateToolbar();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (clearBtn) {
|
||||||
|
clearBtn.addEventListener('click', function () {
|
||||||
|
allBoxes().forEach(function (cb) { cb.checked = false; });
|
||||||
|
if (selectAll) { selectAll.checked = false; selectAll.indeterminate = false; }
|
||||||
|
updateToolbar();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (addBtn && addMenu) {
|
||||||
|
addBtn.addEventListener('click', function (e) {
|
||||||
|
e.stopPropagation();
|
||||||
|
addMenu.classList.toggle('open');
|
||||||
|
});
|
||||||
|
document.addEventListener('click', function () { addMenu.classList.remove('open'); });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newPlBtn) {
|
||||||
|
newPlBtn.addEventListener('click', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
var ids = getIds();
|
||||||
|
if (!ids.length) return;
|
||||||
|
window.location.href = '/mymusic/playlists/new?songs=' + ids.join(',');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('click', function (e) {
|
||||||
|
var item = e.target.closest('.song-sel-pl-item');
|
||||||
|
if (!item) return;
|
||||||
|
e.preventDefault();
|
||||||
|
var playlistId = item.dataset.playlistId;
|
||||||
|
var songIds = getIds();
|
||||||
|
if (!songIds.length || !playlistId) return;
|
||||||
|
var body = new URLSearchParams({ playlistId: playlistId, _csrf: csrfToken });
|
||||||
|
songIds.forEach(function (id) { body.append('songIds[]', id); });
|
||||||
|
fetch('/mymusic/songs/bulk-add', { method: 'POST', body: body })
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.then(function (data) {
|
||||||
|
if (data.ok) {
|
||||||
|
allBoxes().forEach(function (cb) { cb.checked = false; });
|
||||||
|
updateToolbar();
|
||||||
|
showToast('Added ' + data.added + ' song' + (data.added === 1 ? '' : 's') + ' to playlist');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function (err) { console.error(err); });
|
||||||
|
addMenu.classList.remove('open');
|
||||||
|
});
|
||||||
|
|
||||||
|
function showToast(msg) {
|
||||||
|
var t = document.createElement('div');
|
||||||
|
t.className = 'bulk-toast';
|
||||||
|
t.textContent = msg;
|
||||||
|
document.body.appendChild(t);
|
||||||
|
setTimeout(function () { t.classList.add('visible'); }, 10);
|
||||||
|
setTimeout(function () { t.classList.remove('visible'); setTimeout(function () { t.remove(); }, 300); }, 2800);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
<% } %>
|
<% } %>
|
||||||
|
|
||||||
<% if (totalPages > 1) { %>
|
<% if (totalPages > 1) { %>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue