feat(admin): add bulk visibility update for songs

- Add `/songs/bulk-visibility` POST endpoint to update visibility of multiple selected songs in bulk
  - Enforces VIP permission checks before allowing 'vip' visibility setting
  - Updates only songs user has permission to manage

- Extend song list UI with a new "Change Visibility" dropdown button and menu
  - Menu items are conditionally rendered based on user role/VIP status
  - Integrated with existing bulk action toolbar (toggles, prevents overlap)

- Implement frontend logic to:
  - Toggle visibility dropdown independently from other bulk menus
  - Submit selected song IDs and chosen visibility via fetch POST request
  - Update UI in-place after successful update (badges, selection reset)
  - Show toast notification with count of updated songs

- Includes badge rendering helper for consistent visibility display
This commit is contained in:
Brian Fertig 2026-04-14 19:25:10 -06:00
parent 771165f042
commit dc64286b15
2 changed files with 82 additions and 0 deletions

View File

@ -208,6 +208,28 @@ module.exports = function adminRoutes(csrfProtection) {
} catch (err) { next(err); }
});
router.post('/songs/bulk-visibility', csrfProtection, (req, res, next) => {
try {
const canSetVip = req.session.user.isVip || req.session.user.role === 'admin';
let visibility = req.body.visibility;
if (!VISIBILITY_VALUES.includes(visibility)) return res.status(400).json({ error: 'Invalid visibility' });
if (visibility === 'vip' && !canSetVip) return res.status(403).json({ error: 'VIP permission required' });
const songIds = [].concat(req.body['songIds[]'] || []).map(Number).filter(Boolean);
let updated = 0;
for (const songId of songIds) {
const song = songs.findById(songId);
if (!song || !canManage(song, req.session.user)) continue;
songs.update(songId, {
title: song.title, artist: song.artist,
album: song.album || null, genre: song.genre || null,
year: song.year ?? null, visibility,
});
updated++;
}
res.json({ ok: true, updated, visibility });
} catch (err) { next(err); }
});
router.get('/songs/new', (req, res) => {
res.render('mymusic/song_form', { title: 'New song', song: null, errors: {}, values: { visibility: 'logged_in' }, genres: songs.listGenres() });
});

View File

@ -31,6 +31,17 @@
</li>
</ul>
</div>
<div class="bulk-dropdown">
<button type="button" id="bulk-vis-btn" class="btn-secondary">Change Visibility &#9660;</button>
<ul class="bulk-menu" id="bulk-vis-menu">
<li><a class="bulk-vis-item" data-visibility="public" href="#">Public</a></li>
<li><a class="bulk-vis-item" data-visibility="logged_in" href="#">Members Only</a></li>
<% if (user && (user.isVip || user.role === 'admin')) { %>
<li><a class="bulk-vis-item" data-visibility="vip" href="#">VIP Only</a></li>
<% } %>
<li><a class="bulk-vis-item" data-visibility="private" href="#">Private</a></li>
</ul>
</div>
</div>
<table class="data-table">
@ -76,6 +87,8 @@
var countSpan = document.getElementById('bulk-count');
var addBtn = document.getElementById('bulk-add-btn');
var menu = document.getElementById('bulk-menu');
var visBtn = document.getElementById('bulk-vis-btn');
var visMenu = document.getElementById('bulk-vis-menu');
var newBtn = document.getElementById('bulk-new-playlist');
var selectAll = document.getElementById('select-all');
var csrfToken = (document.querySelector('meta[name="csrf-token"]') || {}).content || '';
@ -106,10 +119,18 @@
addBtn.addEventListener('click', function(e) {
e.stopPropagation();
menu.classList.toggle('open');
visMenu.classList.remove('open');
});
visBtn.addEventListener('click', function(e) {
e.stopPropagation();
visMenu.classList.toggle('open');
menu.classList.remove('open');
});
document.addEventListener('click', function() {
menu.classList.remove('open');
visMenu.classList.remove('open');
});
newBtn.addEventListener('click', function(e) {
@ -142,6 +163,45 @@
menu.classList.remove('open');
});
document.addEventListener('click', function(e) {
var item = e.target.closest('.bulk-vis-item');
if (!item) return;
e.preventDefault();
var visibility = item.dataset.visibility;
var songIds = getIds();
if (!songIds.length || !visibility) return;
var body = new URLSearchParams({ visibility: visibility, _csrf: csrfToken });
songIds.forEach(function(id){ body.append('songIds[]', id); });
fetch('/mymusic/songs/bulk-visibility', { method: 'POST', body: body })
.then(function(r){ return r.json(); })
.then(function(data) {
if (data.ok) {
songIds.forEach(function(id) {
var cb = document.querySelector('.song-select[value="' + id + '"]');
if (!cb) return;
var cells = cb.closest('tr').querySelectorAll('td');
if (cells[4]) cells[4].innerHTML = badgeHtml(data.visibility);
});
allBoxes().forEach(function(cb){ cb.checked = false; });
selectAll.checked = false;
updateToolbar();
showToast('Visibility updated for ' + data.updated + ' song' + (data.updated === 1 ? '' : 's'));
}
})
.catch(function(err){ console.error(err); });
visMenu.classList.remove('open');
});
function badgeHtml(v) {
var map = {
public: '<span class="badge badge-public">Public</span>',
logged_in: '<span class="badge badge-logged-in">Members</span>',
vip: '<span class="badge badge-vip">VIP</span>',
private: '<span class="badge badge-private">Private</span>',
};
return map[v] || '';
}
function showToast(msg) {
var t = document.createElement('div');
t.className = 'bulk-toast';