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:
parent
335e7d46f6
commit
983b00f375
|
|
@ -1296,6 +1296,177 @@ a.detail-creator-card:hover {
|
||||||
flex-shrink: 0;
|
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 ---------- */
|
/* ---------- Notifications ---------- */
|
||||||
.notif-nav-btn {
|
.notif-nav-btn {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
|
|
|
||||||
|
|
@ -154,7 +154,28 @@ module.exports = function adminRoutes(csrfProtection) {
|
||||||
|
|
||||||
router.get('/songs', (req, res) => {
|
router.get('/songs', (req, res) => {
|
||||||
const songList = songs.listByUser(req.session.user.id);
|
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) => {
|
router.get('/songs/new', (req, res) => {
|
||||||
|
|
@ -280,7 +301,8 @@ module.exports = function adminRoutes(csrfProtection) {
|
||||||
|
|
||||||
router.get('/playlists/new', (req, res) => {
|
router.get('/playlists/new', (req, res) => {
|
||||||
const allSongs = songs.listForManagement(req.session.user.id);
|
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) => {
|
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;
|
for (const i of parsed.error.issues) errors[i.path[0]] = i.message;
|
||||||
if (req.files?.cover?.[0]) unlinkQuiet(req.files.cover[0].path);
|
if (req.files?.cover?.[0]) unlinkQuiet(req.files.cover[0].path);
|
||||||
const allSongs = songs.listForManagement(user.id);
|
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 coverFile = req.files?.cover?.[0];
|
||||||
const created = playlists.create({
|
const created = playlists.create({
|
||||||
|
|
@ -302,6 +324,15 @@ module.exports = function adminRoutes(csrfProtection) {
|
||||||
visibility: parsed.data.visibility,
|
visibility: parsed.data.visibility,
|
||||||
createdBy: req.session.user.id,
|
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.');
|
req.flash('success', 'Playlist created.');
|
||||||
res.redirect(`/mymusic/playlists/${created.id}/edit`);
|
res.redirect(`/mymusic/playlists/${created.id}/edit`);
|
||||||
} catch (err) { next(err); }
|
} catch (err) { next(err); }
|
||||||
|
|
@ -320,6 +351,7 @@ module.exports = function adminRoutes(csrfProtection) {
|
||||||
allSongs,
|
allSongs,
|
||||||
errors: {},
|
errors: {},
|
||||||
values: { ...playlist },
|
values: { ...playlist },
|
||||||
|
preselectIds: [],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,14 @@
|
||||||
</label>
|
</label>
|
||||||
<p class="muted small">Note: adding a more-restrictive song will automatically upgrade the playlist's visibility.</p>
|
<p class="muted small">Note: adding a more-restrictive song will automatically upgrade the playlist's visibility.</p>
|
||||||
</fieldset>
|
</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">
|
<div class="row">
|
||||||
<button type="submit" class="btn-primary"><%= playlist ? 'Save' : 'Create' %></button>
|
<button type="submit" class="btn-primary"><%= playlist ? 'Save' : 'Create' %></button>
|
||||||
<a href="/mymusic/playlists" class="btn-link">Cancel</a>
|
<a href="/mymusic/playlists" class="btn-link">Cancel</a>
|
||||||
|
|
|
||||||
|
|
@ -11,11 +11,39 @@
|
||||||
<% if (songs.length === 0) { %>
|
<% if (songs.length === 0) { %>
|
||||||
<p class="muted">No songs yet.</p>
|
<p class="muted">No songs yet.</p>
|
||||||
<% } else { %>
|
<% } 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 ▼</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">
|
<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>
|
<tbody>
|
||||||
<% songs.forEach((s) => { %>
|
<% songs.forEach((s) => { %>
|
||||||
<tr>
|
<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><a href="/songs/<%= s.slug %>"><%= s.title %></a></td>
|
||||||
<td><%= s.artist %></td>
|
<td><%= s.artist %></td>
|
||||||
<td><%= s.album || '' %></td>
|
<td><%= s.album || '' %></td>
|
||||||
|
|
@ -39,4 +67,91 @@
|
||||||
<% }) %>
|
<% }) %>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</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>
|
||||||
|
|
||||||
<% } %>
|
<% } %>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue