feat: add song selection toolbar and popular songs section

- Enable bulk song selection with custom checkboxes and select-all header row
- Add "Most Popular" section (scored by likes + 2×favorites) alongside Trending
- Improve horizontal scrolling for liked strips with grab/grabbing cursor and drag-to-scroll logic
- Introduce neon divider (`hr.hr-neon`) for visual separation between sections
- Update CSS grid layout for selectable song lists to accommodate checkboxes
This commit is contained in:
Brian Fertig 2026-04-14 20:12:45 -06:00
parent dc64286b15
commit 3586e1da9a
5 changed files with 348 additions and 7 deletions

View File

@ -553,7 +553,9 @@ a.detail-creator-card:hover {
display: flex; gap: 0.75rem; overflow-x: auto;
padding-bottom: 0.4rem; scrollbar-width: thin;
scrollbar-color: var(--border) transparent;
cursor: grab;
}
.liked-strip-scroll:active { cursor: grabbing; }
.liked-card {
flex: 0 0 auto; display: flex; align-items: center; gap: 0.55rem;
background: var(--bg-panel); border: 1px solid var(--border);
@ -1396,6 +1398,15 @@ a.detail-creator-card:hover {
background: linear-gradient(to right, transparent, var(--neon-cyan), transparent);
box-shadow: 0 0 8px var(--neon-cyan), 0 0 20px rgba(0, 255, 255, 0.2);
}
/* Standalone neon divider — matches profile section borders */
hr.hr-neon {
border: none;
height: 1px;
background: linear-gradient(to right, transparent, var(--neon-cyan), transparent);
box-shadow: 0 0 8px var(--neon-cyan), 0 0 20px rgba(0, 255, 255, 0.2);
margin: 2rem 0;
}
/* No line/extra space before the first section */
.profile-header + section {
margin-top: 1rem;
@ -1671,7 +1682,7 @@ a.detail-creator-card:hover {
box-shadow: 0 0 6px rgba(143, 91, 255, 0.3);
}
#bulk-toolbar {
#bulk-toolbar, #song-sel-toolbar {
display: flex;
align-items: center;
gap: 0.75rem;
@ -1679,6 +1690,52 @@ a.detail-creator-card:hover {
margin-bottom: 0.4rem;
font-size: 0.85rem;
color: var(--muted);
flex-wrap: wrap;
}
/* Select-all header row */
.song-list-select-all-row {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.3rem 0.6rem 0.3rem 0.6rem;
border-bottom: 1px solid rgba(143, 91, 255, 0.18);
}
.song-list-select-all-row label { cursor: pointer; user-select: none; }
/* Song-list checkboxes (selectable mode) */
.song-select, #song-select-all {
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;
}
.song-select:hover, #song-select-all:hover { box-shadow: 0 0 6px rgba(143, 91, 255, 0.4); }
.song-select:checked, #song-select-all:checked {
background: var(--neon-violet);
box-shadow: 0 0 8px rgba(143, 91, 255, 0.6);
}
.song-select:checked::after, #song-select-all:checked::after {
content: '✓';
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.65rem;
color: #fff;
font-weight: 700;
}
#song-select-all:indeterminate {
background: rgba(143, 91, 255, 0.35);
box-shadow: 0 0 6px rgba(143, 91, 255, 0.3);
}
.bulk-dropdown { position: relative; }
@ -1945,6 +2002,13 @@ a.detail-creator-card:hover {
align-items: start;
padding: 0.65rem 0;
}
.song-list--selectable .song-row {
grid-template-columns: 16px 52px 1fr auto;
grid-template-areas:
"select cover main play"
"select cover social play";
}
.song-list--selectable .song-row .song-select { grid-area: select; align-self: center; }
.song-row .song-cover { grid-area: cover; align-self: center; width: 52px; height: 52px; }
.song-row .song-main { grid-area: main; align-self: center; }
.song-row .song-social { grid-area: social; align-self: center; }

View File

@ -69,13 +69,18 @@ router.get('/songs', (req, res) => {
const genres = songs.listGenres();
const uploaders = songs.listUploaders(loggedIn, isVip);
const recentlyLiked = social.getRecentlyLikedSongs(5, loggedIn, isVip).map(songs.toView);
const recentlyLiked = social.getRecentlyLikedSongs(20, loggedIn, isVip).map(songs.toView);
social.enrichSongs(recentlyLiked, userId);
const mostPopularRaw = social.getMostPopular(20, loggedIn, isVip).map(songs.toView);
social.enrichSongs(mostPopularRaw, userId);
const userPlaylists = isVip && userId ? playlists.listByUser(userId) : [];
res.render('public/songs', {
title: 'Songs', songs: rows, q, genre, uploadedBy,
page, pageSize, total, totalPages: Math.max(1, Math.ceil(total / pageSize)),
genres, uploaders, recentlyLiked,
genres, uploaders, recentlyLiked, mostPopular: mostPopularRaw, userPlaylists,
});
});

View File

@ -189,10 +189,52 @@ function getRecentlyLikedSongs(n = 5, loggedIn = false, isVip = false) {
return (isVip ? recentlyLikedVipStmt : loggedIn ? recentlyLikedUserStmt : recentlyLikedGuestStmt).all(n);
}
// ── Most popular songs (scored by likes + 2×favorites) ───────────────────────
const mostPopularGuestStmt = db.prepare(`
SELECT s.*,
(COUNT(DISTINCT sl.user_id) + 2 * COUNT(DISTINCT sf.user_id)) AS score
FROM songs s
LEFT JOIN song_likes sl ON sl.song_id = s.id
LEFT JOIN song_favorites sf ON sf.song_id = s.id
WHERE s.visibility = 'public'
GROUP BY s.id
HAVING score > 0
ORDER BY score DESC, s.created_at DESC
LIMIT ?
`);
const mostPopularUserStmt = db.prepare(`
SELECT s.*,
(COUNT(DISTINCT sl.user_id) + 2 * COUNT(DISTINCT sf.user_id)) AS score
FROM songs s
LEFT JOIN song_likes sl ON sl.song_id = s.id
LEFT JOIN song_favorites sf ON sf.song_id = s.id
WHERE s.visibility IN ('public', 'logged_in')
GROUP BY s.id
HAVING score > 0
ORDER BY score DESC, s.created_at DESC
LIMIT ?
`);
const mostPopularVipStmt = db.prepare(`
SELECT s.*,
(COUNT(DISTINCT sl.user_id) + 2 * COUNT(DISTINCT sf.user_id)) AS score
FROM songs s
LEFT JOIN song_likes sl ON sl.song_id = s.id
LEFT JOIN song_favorites sf ON sf.song_id = s.id
WHERE s.visibility IN ('public', 'logged_in', 'vip')
GROUP BY s.id
HAVING score > 0
ORDER BY score DESC, s.created_at DESC
LIMIT ?
`);
function getMostPopular(n = 20, loggedIn = false, isVip = false) {
return (isVip ? mostPopularVipStmt : loggedIn ? mostPopularUserStmt : mostPopularGuestStmt).all(n);
}
module.exports = {
toggleSongLike, toggleSongFavorite,
togglePlaylistLike, togglePlaylistFavorite,
enrichSongs, enrichPlaylists,
getLikedSongs, getLikedPlaylists,
getRecentlyLikedSongs,
getRecentlyLikedSongs, getMostPopular,
};

View File

@ -1,6 +1,15 @@
<ul class="song-list">
<% if (typeof selectable !== 'undefined' && selectable) { %>
<div class="song-list-select-all-row">
<input type="checkbox" id="song-select-all" aria-label="Select all songs on this page">
<label for="song-select-all" class="muted small">Select all</label>
</div>
<% } %>
<ul class="song-list<%= (typeof selectable !== 'undefined' && selectable) ? ' song-list--selectable' : '' %>">
<% songs.forEach((s) => { %>
<li class="song-row" data-song-id="<%= s.id %>">
<% if (typeof selectable !== 'undefined' && selectable) { %>
<input type="checkbox" class="song-select" value="<%= s.id %>" aria-label="Select <%= s.title %>">
<% } %>
<% if (s.coverPath) { %>
<img src="/media/<%= s.coverPath %>" alt="" class="song-cover" />
<% } else { %>

View File

@ -3,7 +3,7 @@
<% if (recentlyLiked && recentlyLiked.length > 0) { %>
<section class="liked-strip">
<h2 class="liked-strip-heading">Trending</h2>
<div class="liked-strip-scroll">
<div class="liked-strip-scroll" id="trending-scroll">
<% recentlyLiked.forEach((s) => { %>
<a href="/songs/<%= s.slug %>" class="liked-card">
<% if (s.coverPath) { %>
@ -19,8 +19,81 @@
<% }) %>
</div>
</section>
<script>
(function () {
var el = document.getElementById('trending-scroll');
if (!el) return;
var dragging = false, startX, scrollLeft;
el.addEventListener('mousedown', function (e) {
dragging = true;
startX = e.pageX - el.offsetLeft;
scrollLeft = el.scrollLeft;
el.style.cursor = 'grabbing';
el.style.userSelect = 'none';
});
document.addEventListener('mouseup', function () {
dragging = false;
el.style.cursor = '';
el.style.userSelect = '';
});
document.addEventListener('mousemove', function (e) {
if (!dragging) return;
e.preventDefault();
var x = e.pageX - el.offsetLeft;
el.scrollLeft = scrollLeft - (x - startX);
});
})();
</script>
<% } %>
<% if (mostPopular && mostPopular.length > 0) { %>
<section class="liked-strip">
<h2 class="liked-strip-heading">Most Popular</h2>
<div class="liked-strip-scroll" id="popular-scroll">
<% mostPopular.forEach((s) => { %>
<a href="/songs/<%= s.slug %>" class="liked-card">
<% if (s.coverPath) { %>
<img src="/media/<%= s.coverPath %>" alt="" class="liked-card-cover">
<% } else { %>
<div class="liked-card-cover liked-card-cover--fallback">♪</div>
<% } %>
<span class="liked-card-info">
<span class="liked-card-title"><%= s.title %></span>
<span class="liked-card-artist"><%= s.artist %></span>
</span>
</a>
<% }) %>
</div>
</section>
<script>
(function () {
var el = document.getElementById('popular-scroll');
if (!el) return;
var dragging = false, startX, scrollLeft;
el.addEventListener('mousedown', function (e) {
dragging = true;
startX = e.pageX - el.offsetLeft;
scrollLeft = el.scrollLeft;
el.style.cursor = 'grabbing';
el.style.userSelect = 'none';
});
document.addEventListener('mouseup', function () {
dragging = false;
el.style.cursor = '';
el.style.userSelect = '';
});
document.addEventListener('mousemove', function (e) {
if (!dragging) return;
e.preventDefault();
var x = e.pageX - el.offsetLeft;
el.scrollLeft = scrollLeft - (x - startX);
});
})();
</script>
<% } %>
<hr class="hr-neon">
<form method="get" action="/songs" class="search-form">
<div class="search-row">
<input type="search" name="q" value="<%= q %>" placeholder="Search title, artist, album…" />
@ -54,7 +127,155 @@
<% if (songs.length === 0) { %>
<p class="muted">No songs found.</p>
<% } else { %>
<%- include('../partials/song_list', { songs }) %>
<div id="song-sel-toolbar" style="display:none">
<span><span id="song-sel-count">0</span> selected</span>
<button type="button" id="song-sel-play" class="btn-primary" data-play-playlist="[]" data-nowplaying-meta="{}">▶ Play Selected</button>
<button type="button" id="song-sel-shuffle" class="btn-primary" data-shuffle-playlist="[]" data-nowplaying-meta="{}">⇄ Shuffle Selected</button>
<% if (user && (user.isVip || user.role === 'admin')) { %>
<div class="bulk-dropdown">
<button type="button" id="song-sel-add-btn" class="btn-secondary">Add to Playlist &#9660;</button>
<ul class="bulk-menu" id="song-sel-add-menu">
<li><a id="song-sel-new-playlist" href="#">+ New Playlist</a></li>
<li class="has-submenu">
<span>Existing Playlist</span>
<ul class="bulk-submenu">
<% userPlaylists.forEach(function(pl) { %>
<li><a class="song-sel-pl-item" data-playlist-id="<%= pl.id %>" href="#"><%= pl.title %></a></li>
<% }) %>
<% if (!userPlaylists.length) { %>
<li class="bulk-submenu-empty">No playlists yet</li>
<% } %>
</ul>
</li>
</ul>
</div>
<% } %>
<button type="button" id="song-sel-clear" class="linklike muted small">Clear</button>
</div>
<%- include('../partials/song_list', { songs, selectable: true }) %>
<script>
(function () {
var toolbar = document.getElementById('song-sel-toolbar');
var countEl = document.getElementById('song-sel-count');
var playBtn = document.getElementById('song-sel-play');
var shuffleBtn = document.getElementById('song-sel-shuffle');
var clearBtn = document.getElementById('song-sel-clear');
var selectAll = document.getElementById('song-select-all');
var addBtn = document.getElementById('song-sel-add-btn');
var addMenu = document.getElementById('song-sel-add-menu');
var newPlBtn = document.getElementById('song-sel-new-playlist');
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 getSongData(cb) {
var row = cb.closest('.song-row');
var btn = row && row.querySelector('[data-play-song]');
if (!btn) return null;
try { return JSON.parse(btn.getAttribute('data-play-song')); } catch (e) { return null; }
}
function getIds() { return Array.from(checkedBoxes()).map(function (cb) { return cb.value; }); }
function updateToolbar() {
var checked = Array.from(checkedBoxes());
var total = allBoxes().length;
toolbar.style.display = checked.length ? '' : 'none';
countEl.textContent = checked.length;
if (selectAll) {
selectAll.indeterminate = checked.length > 0 && checked.length < total;
selectAll.checked = checked.length > 0 && checked.length === total;
}
if (checked.length) {
var list = checked.map(getSongData).filter(Boolean);
var json = JSON.stringify(list);
var npMeta = JSON.stringify({
title: checked.length + ' Selected Song' + (checked.length === 1 ? '' : 's'),
description: null,
cover: list.length === 1 ? (list[0].cover || null) : null,
trackCount: list.length,
creator: null,
});
playBtn.setAttribute('data-play-playlist', json);
playBtn.setAttribute('data-nowplaying-meta', npMeta);
shuffleBtn.setAttribute('data-shuffle-playlist', json);
shuffleBtn.setAttribute('data-nowplaying-meta', npMeta);
}
}
document.addEventListener('change', function (e) {
if (e.target.matches('.song-select')) updateToolbar();
});
if (selectAll) {
selectAll.addEventListener('change', function () {
allBoxes().forEach(function (cb) { cb.checked = selectAll.checked; });
updateToolbar();
});
}
if (clearBtn) {
clearBtn.addEventListener('click', function () {
allBoxes().forEach(function (cb) { cb.checked = false; });
if (selectAll) { selectAll.checked = false; selectAll.indeterminate = false; }
updateToolbar();
});
}
if (addBtn && addMenu) {
addBtn.addEventListener('click', function (e) {
e.stopPropagation();
addMenu.classList.toggle('open');
});
document.addEventListener('click', function () { addMenu.classList.remove('open'); });
}
if (newPlBtn) {
newPlBtn.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('.song-sel-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; });
updateToolbar();
showToast('Added ' + data.added + ' song' + (data.added === 1 ? '' : 's') + ' to playlist');
}
})
.catch(function (err) { console.error(err); });
addMenu.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>
<% } %>
<% if (totalPages > 1) { %>