feat(admin): add bulk song selection with playlist assignment

- Introduces bulk checkboxes on the songs list page to select multiple songs
- Adds a toolbar with dropdown to add selected songs to existing playlists or create a new playlist
- Implements `/songs/bulk-add` POST endpoint for efficient batch addition of tracks to playlists
- Auto-upgrades playlist visibility when adding more-restrictive songs
- Supports preselecting songs via query parameter when creating a new playlist (`?songs=1,2,3`)
- Includes custom-styled checkboxes, toast notifications, and keyboard-friendly UI interactions
This commit is contained in:
Brian Fertig 2026-04-12 13:58:22 -06:00
parent 335e7d46f6
commit 983b00f375
4 changed files with 330 additions and 4 deletions

View File

@ -1296,6 +1296,177 @@ a.detail-creator-card:hover {
flex-shrink: 0;
}
/* ---------- Bulk song selection ---------- */
.col-check {
width: 2.2rem;
text-align: center;
padding-left: 0.5rem !important;
padding-right: 0 !important;
}
.col-check input[type="checkbox"] {
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;
}
.col-check input[type="checkbox"]:hover {
box-shadow: 0 0 6px rgba(143, 91, 255, 0.4);
}
.col-check input[type="checkbox"]:checked {
background: var(--neon-violet);
box-shadow: 0 0 8px rgba(143, 91, 255, 0.6);
}
.col-check input[type="checkbox"]:checked::after {
content: '✓';
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.65rem;
color: #fff;
font-weight: 700;
}
.col-check input[type="checkbox"]:indeterminate {
background: rgba(143, 91, 255, 0.35);
box-shadow: 0 0 6px rgba(143, 91, 255, 0.3);
}
#bulk-toolbar {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.5rem 0;
margin-bottom: 0.4rem;
font-size: 0.85rem;
color: var(--muted);
}
.bulk-dropdown { position: relative; }
.bulk-menu {
display: none;
position: absolute;
top: calc(100% + 4px);
left: 0;
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: var(--radius);
list-style: none;
margin: 0;
padding: 0.25rem 0;
min-width: 190px;
z-index: 200;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.6);
}
.bulk-menu.open { display: block; }
.bulk-menu li { position: relative; }
.bulk-menu li a,
.bulk-menu li > span {
display: block;
padding: 0.5rem 1rem;
color: var(--fg);
text-decoration: none;
font-size: 0.85rem;
cursor: pointer;
white-space: nowrap;
user-select: none;
}
.bulk-menu li a:hover,
.bulk-menu li > span:hover {
background: rgba(143, 91, 255, 0.15);
color: var(--neon-violet);
}
.bulk-menu li.has-submenu > span::after {
content: ' \25B6';
font-size: 0.65rem;
opacity: 0.7;
}
.bulk-submenu {
display: none;
position: absolute;
top: -0.25rem;
left: 100%;
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: var(--radius);
list-style: none;
margin: 0;
padding: 0.25rem 0;
min-width: 210px;
max-height: 230px;
overflow-y: auto;
z-index: 201;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.6);
scrollbar-width: thin;
scrollbar-color: var(--border) transparent;
}
.bulk-submenu::-webkit-scrollbar { width: 5px; }
.bulk-submenu::-webkit-scrollbar-track { background: transparent; }
.bulk-submenu::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
.bulk-menu li.has-submenu:hover .bulk-submenu { display: block; }
.bulk-submenu li a {
display: block;
padding: 0.45rem 1rem;
color: var(--fg);
text-decoration: none;
font-size: 0.85rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.bulk-submenu li a:hover {
background: rgba(143, 91, 255, 0.15);
color: var(--neon-violet);
}
.bulk-submenu-empty {
padding: 0.5rem 1rem;
color: var(--muted);
font-size: 0.8rem;
}
.preselect-notice {
padding: 0.6rem 0.9rem;
margin-bottom: 0.5rem;
background: rgba(143, 91, 255, 0.08);
border: 1px solid rgba(143, 91, 255, 0.3);
border-radius: var(--radius);
font-size: 0.85rem;
color: var(--muted);
}
.bulk-toast {
position: fixed;
bottom: 5rem;
left: 50%;
transform: translateX(-50%) translateY(8px);
background: var(--bg-panel);
border: 1px solid var(--neon-violet);
color: var(--fg);
padding: 0.55rem 1.2rem;
border-radius: var(--radius);
font-size: 0.85rem;
box-shadow: 0 0 14px rgba(143, 91, 255, 0.4);
opacity: 0;
transition: opacity 0.2s ease, transform 0.2s ease;
z-index: 9999;
pointer-events: none;
white-space: nowrap;
}
.bulk-toast.visible {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
/* ---------- Notifications ---------- */
.notif-nav-btn {
display: inline-flex;

View File

@ -154,7 +154,28 @@ module.exports = function adminRoutes(csrfProtection) {
router.get('/songs', (req, res) => {
const songList = songs.listByUser(req.session.user.id);
res.render('mymusic/songs', { title: 'My Music · Songs', songs: songList });
const playlistList = playlists.listByUser(req.session.user.id);
res.render('mymusic/songs', { title: 'My Music · Songs', songs: songList, playlists: playlistList });
});
router.post('/songs/bulk-add', csrfProtection, (req, res, next) => {
try {
const playlistId = Number(req.body.playlistId);
const songIds = [].concat(req.body['songIds[]'] || []).map(Number).filter(Boolean);
const playlist = playlists.findById(playlistId);
if (!playlist || !canManage(playlist, req.session.user)) {
return res.status(403).json({ error: 'Forbidden' });
}
for (const songId of songIds) {
const song = songs.findById(songId);
if (!song) continue;
playlists.addTrack(playlistId, songId);
if (VISIBILITY_RANK[song.visibility] > VISIBILITY_RANK[playlist.visibility]) {
playlists.setVisibility(playlistId, song.visibility);
}
}
res.json({ ok: true, added: songIds.length });
} catch (err) { next(err); }
});
router.get('/songs/new', (req, res) => {
@ -280,7 +301,8 @@ module.exports = function adminRoutes(csrfProtection) {
router.get('/playlists/new', (req, res) => {
const allSongs = songs.listForManagement(req.session.user.id);
res.render('mymusic/playlist_form', { title: 'New playlist', playlist: null, tracks: [], allSongs, errors: {}, values: { visibility: 'logged_in' } });
const preselectIds = (req.query.songs || '').split(',').map(Number).filter(Boolean);
res.render('mymusic/playlist_form', { title: 'New playlist', playlist: null, tracks: [], allSongs, errors: {}, values: { visibility: 'logged_in' }, preselectIds });
});
router.post('/playlists', playlistFields, csrfProtection, (req, res, next) => {
@ -292,7 +314,7 @@ module.exports = function adminRoutes(csrfProtection) {
for (const i of parsed.error.issues) errors[i.path[0]] = i.message;
if (req.files?.cover?.[0]) unlinkQuiet(req.files.cover[0].path);
const allSongs = songs.listForManagement(user.id);
return res.status(400).render('mymusic/playlist_form', { title: 'New playlist', playlist: null, tracks: [], allSongs, errors, values: req.body });
return res.status(400).render('mymusic/playlist_form', { title: 'New playlist', playlist: null, tracks: [], allSongs, errors, values: req.body, preselectIds: [] });
}
const coverFile = req.files?.cover?.[0];
const created = playlists.create({
@ -302,6 +324,15 @@ module.exports = function adminRoutes(csrfProtection) {
visibility: parsed.data.visibility,
createdBy: req.session.user.id,
});
const preselectIds = [].concat(req.body['preselectIds[]'] || []).map(Number).filter(Boolean);
for (const songId of preselectIds) {
const song = songs.findById(songId);
if (!song) continue;
playlists.addTrack(created.id, songId);
if (VISIBILITY_RANK[song.visibility] > VISIBILITY_RANK[created.visibility]) {
playlists.setVisibility(created.id, song.visibility);
}
}
req.flash('success', 'Playlist created.');
res.redirect(`/mymusic/playlists/${created.id}/edit`);
} catch (err) { next(err); }
@ -320,6 +351,7 @@ module.exports = function adminRoutes(csrfProtection) {
allSongs,
errors: {},
values: { ...playlist },
preselectIds: [],
});
});

View File

@ -33,6 +33,14 @@
</label>
<p class="muted small">Note: adding a more-restrictive song will automatically upgrade the playlist's visibility.</p>
</fieldset>
<% if (!playlist && preselectIds && preselectIds.length) { %>
<div class="preselect-notice">
<strong><%= preselectIds.length %></strong> song<%= preselectIds.length === 1 ? '' : 's' %> will be added after creation.
<% preselectIds.forEach(function(id) { %>
<input type="hidden" name="preselectIds[]" value="<%= id %>">
<% }) %>
</div>
<% } %>
<div class="row">
<button type="submit" class="btn-primary"><%= playlist ? 'Save' : 'Create' %></button>
<a href="/mymusic/playlists" class="btn-link">Cancel</a>

View File

@ -11,11 +11,39 @@
<% if (songs.length === 0) { %>
<p class="muted">No songs yet.</p>
<% } else { %>
<div id="bulk-toolbar" style="display:none">
<span><span id="bulk-count">0</span> selected</span>
<div class="bulk-dropdown">
<button type="button" id="bulk-add-btn" class="btn-secondary">Add to Playlist &#9660;</button>
<ul class="bulk-menu" id="bulk-menu">
<li><a id="bulk-new-playlist" href="#">+ New Playlist</a></li>
<li class="has-submenu">
<span>Existing Playlist</span>
<ul class="bulk-submenu">
<% playlists.forEach(function(pl) { %>
<li><a class="bulk-pl-item" data-playlist-id="<%= pl.id %>" href="#"><%= pl.title %></a></li>
<% }) %>
<% if (!playlists.length) { %>
<li class="bulk-submenu-empty">No playlists yet</li>
<% } %>
</ul>
</li>
</ul>
</div>
</div>
<table class="data-table">
<thead><tr><th>Title</th><th>Artist</th><th>Album</th><th>Visibility</th><th></th></tr></thead>
<thead>
<tr>
<th class="col-check"><input type="checkbox" id="select-all" title="Select all"></th>
<th>Title</th><th>Artist</th><th>Album</th><th>Visibility</th><th></th>
</tr>
</thead>
<tbody>
<% songs.forEach((s) => { %>
<tr>
<td class="col-check"><input type="checkbox" class="song-select" value="<%= s.id %>"></td>
<td><a href="/songs/<%= s.slug %>"><%= s.title %></a></td>
<td><%= s.artist %></td>
<td><%= s.album || '' %></td>
@ -39,4 +67,91 @@
<% }) %>
</tbody>
</table>
<script>
(function () {
var toolbar = document.getElementById('bulk-toolbar');
var countSpan = document.getElementById('bulk-count');
var addBtn = document.getElementById('bulk-add-btn');
var menu = document.getElementById('bulk-menu');
var newBtn = document.getElementById('bulk-new-playlist');
var selectAll = document.getElementById('select-all');
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 getIds() { return Array.from(checkedBoxes()).map(function(el){ return el.value; }); }
function updateToolbar() {
var checked = checkedBoxes().length;
var total = allBoxes().length;
toolbar.style.display = checked ? '' : 'none';
countSpan.textContent = checked;
selectAll.indeterminate = checked > 0 && checked < total;
selectAll.checked = checked > 0 && checked === total;
}
document.addEventListener('change', function(e) {
if (e.target.matches('#select-all')) {
var state = e.target.checked;
allBoxes().forEach(function(cb){ cb.checked = state; });
}
if (e.target.matches('.song-select') || e.target.matches('#select-all')) {
updateToolbar();
}
});
addBtn.addEventListener('click', function(e) {
e.stopPropagation();
menu.classList.toggle('open');
});
document.addEventListener('click', function() {
menu.classList.remove('open');
});
newBtn.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('.bulk-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; });
selectAll.checked = false;
updateToolbar();
showToast('Added ' + data.added + ' song' + (data.added === 1 ? '' : 's') + ' to playlist');
}
})
.catch(function(err){ console.error(err); });
menu.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>
<% } %>