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:
parent
771165f042
commit
dc64286b15
|
|
@ -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() });
|
||||
});
|
||||
|
|
|
|||
|
|
@ -31,6 +31,17 @@
|
|||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="bulk-dropdown">
|
||||
<button type="button" id="bulk-vis-btn" class="btn-secondary">Change Visibility ▼</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';
|
||||
|
|
|
|||
Loading…
Reference in New Issue