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); }
|
} 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) => {
|
router.get('/songs/new', (req, res) => {
|
||||||
res.render('mymusic/song_form', { title: 'New song', song: null, errors: {}, values: { visibility: 'logged_in' }, genres: songs.listGenres() });
|
res.render('mymusic/song_form', { title: 'New song', song: null, errors: {}, values: { visibility: 'logged_in' }, genres: songs.listGenres() });
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,17 @@
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<table class="data-table">
|
<table class="data-table">
|
||||||
|
|
@ -76,6 +87,8 @@
|
||||||
var countSpan = document.getElementById('bulk-count');
|
var countSpan = document.getElementById('bulk-count');
|
||||||
var addBtn = document.getElementById('bulk-add-btn');
|
var addBtn = document.getElementById('bulk-add-btn');
|
||||||
var menu = document.getElementById('bulk-menu');
|
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 newBtn = document.getElementById('bulk-new-playlist');
|
||||||
var selectAll = document.getElementById('select-all');
|
var selectAll = document.getElementById('select-all');
|
||||||
var csrfToken = (document.querySelector('meta[name="csrf-token"]') || {}).content || '';
|
var csrfToken = (document.querySelector('meta[name="csrf-token"]') || {}).content || '';
|
||||||
|
|
@ -106,10 +119,18 @@
|
||||||
addBtn.addEventListener('click', function(e) {
|
addBtn.addEventListener('click', function(e) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
menu.classList.toggle('open');
|
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() {
|
document.addEventListener('click', function() {
|
||||||
menu.classList.remove('open');
|
menu.classList.remove('open');
|
||||||
|
visMenu.classList.remove('open');
|
||||||
});
|
});
|
||||||
|
|
||||||
newBtn.addEventListener('click', function(e) {
|
newBtn.addEventListener('click', function(e) {
|
||||||
|
|
@ -142,6 +163,45 @@
|
||||||
menu.classList.remove('open');
|
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) {
|
function showToast(msg) {
|
||||||
var t = document.createElement('div');
|
var t = document.createElement('div');
|
||||||
t.className = 'bulk-toast';
|
t.className = 'bulk-toast';
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue