feat: implement social actions (like/favorite) for songs and playlists

- Add database tables for song/playlist likes and favorites
- Create `social` service with toggle functions and enrichment helpers
- Implement `/api/:type/:id/(like|favorite)` routes with CSRF protection
- Add like/favorite buttons to player, song lists, playlists, and cards
- Support guest → login redirect via `data-require-login`
- Inject CSRF token into layout for JS API requests
This commit is contained in:
Brian Fertig 2026-04-11 09:12:21 -06:00
parent a24f2fe75d
commit f044d98afc
15 changed files with 331 additions and 9 deletions

View File

@ -0,0 +1,24 @@
CREATE TABLE song_likes (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
song_id INTEGER NOT NULL REFERENCES songs(id) ON DELETE CASCADE,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (user_id, song_id)
);
CREATE TABLE song_favorites (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
song_id INTEGER NOT NULL REFERENCES songs(id) ON DELETE CASCADE,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (user_id, song_id)
);
CREATE TABLE playlist_likes (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
playlist_id INTEGER NOT NULL REFERENCES playlists(id) ON DELETE CASCADE,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (user_id, playlist_id)
);
CREATE TABLE playlist_favorites (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
playlist_id INTEGER NOT NULL REFERENCES playlists(id) ON DELETE CASCADE,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (user_id, playlist_id)
);

View File

@ -486,6 +486,29 @@ button.linklike.danger:hover { color: #fff; text-shadow: 0 0 6px var(--danger);
.data-table .actions { white-space: nowrap; font-size: 0.85rem; } .data-table .actions { white-space: nowrap; font-size: 0.85rem; }
.data-table tr.row-disabled td { opacity: 0.45; } .data-table tr.row-disabled td { opacity: 0.45; }
/* ---------- Social buttons ---------- */
.btn-social {
background: none;
border: none;
cursor: pointer;
color: var(--muted);
font-size: 0.8rem;
padding: 2px 5px;
border-radius: 4px;
display: inline-flex;
align-items: center;
gap: 3px;
transition: color 0.15s, background 0.15s;
line-height: 1;
}
.btn-social:hover { color: var(--fg); background: rgba(255,255,255,0.06); }
.btn-social[data-social-action="like"].active { color: var(--neon-cyan); text-shadow: var(--glow-cyan); }
.btn-social[data-social-action="favorite"].active { color: var(--neon-amber); text-shadow: 0 0 8px rgba(255,179,71,0.7); }
.btn-social:disabled { opacity: 0.5; cursor: wait; }
.song-social { display: flex; align-items: center; gap: 2px; }
.social-row { display: flex; gap: 4px; margin: 0.4rem 0 0.6rem; }
.card-social { display: flex; gap: 4px; margin-top: 0.5rem; padding-top: 0.4rem; border-top: 1px solid rgba(255,255,255,0.06); }
.track-list { padding-left: 0; list-style: none; } .track-list { padding-left: 0; list-style: none; }
.track-list li { margin: 0; display: flex; align-items: center; justify-content: space-between; gap: 0.75rem; padding: 0.35rem 0.5rem; border-radius: 4px; border-top: 2px solid transparent; border-bottom: 2px solid transparent; transition: background 0.15s; } .track-list li { margin: 0; display: flex; align-items: center; justify-content: space-between; gap: 0.75rem; padding: 0.35rem 0.5rem; border-radius: 4px; border-top: 2px solid transparent; border-bottom: 2px solid transparent; transition: background 0.15s; }
.track-list li:hover { background: rgba(255,255,255,0.04); } .track-list li:hover { background: rgba(255,255,255,0.04); }

View File

@ -6,8 +6,25 @@
const queueInfoEl = document.getElementById('player-queue-info'); const queueInfoEl = document.getElementById('player-queue-info');
const nextBtn = document.getElementById('player-next'); const nextBtn = document.getElementById('player-next');
const repeatBtn = document.getElementById('player-repeat'); const repeatBtn = document.getElementById('player-repeat');
const playerLikeBtn = document.getElementById('player-like');
const playerFavBtn = document.getElementById('player-favorite');
let repeatOn = false; let repeatOn = false;
function updatePlayerSocial(song) {
if (!playerLikeBtn || !playerFavBtn) return;
const active = !!song;
playerLikeBtn.disabled = playerFavBtn.disabled = !active;
playerLikeBtn.dataset.socialId = playerFavBtn.dataset.socialId = active ? song.id : '';
playerLikeBtn.classList.toggle('active', active && !!song.userLiked);
playerLikeBtn.setAttribute('aria-pressed', String(active && !!song.userLiked));
playerFavBtn.classList.toggle('active', active && !!song.userFavorited);
playerFavBtn.setAttribute('aria-pressed', String(active && !!song.userFavorited));
const lc = playerLikeBtn.querySelector('.social-count');
const fc = playerFavBtn.querySelector('.social-count');
if (lc) lc.textContent = active ? (song.likeCount ?? 0) : 0;
if (fc) fc.textContent = active ? (song.favoriteCount ?? 0) : 0;
}
const state = { const state = {
queue: [], // array of {id, title, artist, cover} queue: [], // array of {id, title, artist, cover}
index: -1, index: -1,
@ -20,6 +37,7 @@
artistEl.textContent = ''; artistEl.textContent = '';
coverEl.src = '/static/vendor/cover-placeholder.svg'; coverEl.src = '/static/vendor/cover-placeholder.svg';
queueInfoEl.textContent = ''; queueInfoEl.textContent = '';
updatePlayerSocial(null);
return; return;
} }
titleEl.textContent = cur.title; titleEl.textContent = cur.title;
@ -28,6 +46,7 @@
queueInfoEl.textContent = state.queue.length > 1 queueInfoEl.textContent = state.queue.length > 1
? `${state.index + 1} / ${state.queue.length}` ? `${state.index + 1} / ${state.queue.length}`
: ''; : '';
updatePlayerSocial(cur);
} }
function highlightNowPlaying(id) { function highlightNowPlaying(id) {

41
public/js/social.js Normal file
View File

@ -0,0 +1,41 @@
(function () {
const meta = document.querySelector('meta[name="csrf-token"]');
const csrfToken = meta ? meta.content : '';
document.addEventListener('click', async (e) => {
const btn = e.target.closest('[data-social-action]');
if (!btn) return;
// Guest: redirect to login
if (btn.hasAttribute('data-require-login')) {
window.location.href = '/login';
return;
}
// Prevent card <a> from navigating when button is inside one
e.stopPropagation();
const { socialAction, socialType, socialId } = btn.dataset;
const url = `/api/${socialType}s/${socialId}/${socialAction}`;
btn.disabled = true;
try {
const res = await fetch(url, {
method: 'POST',
headers: { 'X-CSRF-Token': csrfToken },
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
const isOn = data.liked ?? data.favorited;
btn.classList.toggle('active', isOn);
btn.setAttribute('aria-pressed', String(isOn));
const countEl = btn.querySelector('.social-count');
if (countEl) countEl.textContent = data.count;
} catch (err) {
console.error('[social] toggle failed:', err);
} finally {
btn.disabled = false;
}
});
})();

View File

@ -46,7 +46,7 @@ app.use(session({
})); }));
const { csrfSynchronisedProtection, generateToken } = csrfSync({ const { csrfSynchronisedProtection, generateToken } = csrfSync({
getTokenFromRequest: (req) => req.body && req.body._csrf, getTokenFromRequest: (req) => req.body?._csrf || req.headers['x-csrf-token'],
}); });
// Attach helpers for templates. // Attach helpers for templates.
@ -69,6 +69,7 @@ app.use((req, _res, next) => {
}); });
// Routes. // Routes.
app.use('/api', require('./routes/social')(csrfSynchronisedProtection));
app.use('/', require('./routes/public')); app.use('/', require('./routes/public'));
app.use('/', require('./routes/auth')(csrfSynchronisedProtection)); app.use('/', require('./routes/auth')(csrfSynchronisedProtection));
app.use('/account', require('./routes/account')(csrfSynchronisedProtection)); app.use('/account', require('./routes/account')(csrfSynchronisedProtection));

View File

@ -4,6 +4,7 @@ const express = require('express');
const songs = require('../services/songs'); const songs = require('../services/songs');
const playlists = require('../services/playlists'); const playlists = require('../services/playlists');
const social = require('../services/social');
const router = express.Router(); const router = express.Router();
const mediaDir = process.env.MEDIA_DIR || path.join(__dirname, '..', '..', 'media'); const mediaDir = process.env.MEDIA_DIR || path.join(__dirname, '..', '..', 'media');
@ -36,15 +37,17 @@ function denyAccess(req, res, next, visibility) {
router.get('/', (req, res) => { router.get('/', (req, res) => {
const loggedIn = !!req.session.user; const loggedIn = !!req.session.user;
const userId = req.session.user?.id || null;
res.render('public/home', { res.render('public/home', {
title: res.locals.siteName, title: res.locals.siteName,
featured: playlists.featured(5, loggedIn), featured: social.enrichPlaylists(playlists.featured(5, loggedIn), userId),
recent: songs.recent(8, loggedIn), recent: social.enrichSongs(songs.recent(8, loggedIn), userId),
}); });
}); });
router.get('/songs', (req, res) => { router.get('/songs', (req, res) => {
const loggedIn = !!req.session.user; const loggedIn = !!req.session.user;
const userId = req.session.user?.id || null;
const page = Math.max(1, Number(req.query.page) || 1); const page = Math.max(1, Number(req.query.page) || 1);
const pageSize = 25; const pageSize = 25;
const { rows, total } = songs.list({ const { rows, total } = songs.list({
@ -53,6 +56,7 @@ router.get('/songs', (req, res) => {
offset: (page - 1) * pageSize, offset: (page - 1) * pageSize,
loggedIn, loggedIn,
}); });
social.enrichSongs(rows, userId);
res.render('public/songs', { res.render('public/songs', {
title: 'Songs', title: 'Songs',
songs: rows, songs: rows,
@ -70,24 +74,30 @@ router.get('/songs/:slug', (req, res, next) => {
if (!canView(song.visibility, song.uploadedBy, req.session.user?.id)) { if (!canView(song.visibility, song.uploadedBy, req.session.user?.id)) {
return denyAccess(req, res, next, song.visibility); return denyAccess(req, res, next, song.visibility);
} }
const userId = req.session.user?.id || null;
social.enrichSongs([song], userId);
res.render('public/song', { title: song.title, song }); res.render('public/song', { title: song.title, song });
}); });
router.get('/playlists', (req, res) => { router.get('/playlists', (req, res) => {
const loggedIn = !!req.session.user; const loggedIn = !!req.session.user;
res.render('public/playlists', { title: 'Playlists', playlists: playlists.listVisible(loggedIn) }); const userId = req.session.user?.id || null;
const list = social.enrichPlaylists(playlists.listVisible(loggedIn), userId);
res.render('public/playlists', { title: 'Playlists', playlists: list });
}); });
router.get('/playlists/:slug', (req, res, next) => { router.get('/playlists/:slug', (req, res, next) => {
const playlist = playlists.findBySlug(req.params.slug); const playlist = playlists.findBySlug(req.params.slug);
if (!playlist) return next(); if (!playlist) return next();
const userId = req.session.user?.id; const userId = req.session.user?.id || null;
// Use effective visibility (most restrictive of playlist + all its tracks). // Use effective visibility (most restrictive of playlist + all its tracks).
const effectiveVisibility = playlists.getEffectiveVisibility(playlist.id); const effectiveVisibility = playlists.getEffectiveVisibility(playlist.id);
if (!canView(effectiveVisibility, playlist.createdBy, userId)) { if (!canView(effectiveVisibility, playlist.createdBy, userId)) {
return denyAccess(req, res, next, effectiveVisibility); return denyAccess(req, res, next, effectiveVisibility);
} }
social.enrichPlaylists([playlist], userId);
const tracks = playlists.getTracks(playlist.id).filter(t => canView(t.visibility, t.uploadedBy, userId)); const tracks = playlists.getTracks(playlist.id).filter(t => canView(t.visibility, t.uploadedBy, userId));
social.enrichSongs(tracks, userId);
res.render('public/playlist', { title: playlist.title, playlist, tracks }); res.render('public/playlist', { title: playlist.title, playlist, tracks });
}); });

44
src/routes/social.js Normal file
View File

@ -0,0 +1,44 @@
const express = require('express');
const { requireUser } = require('../middleware/auth');
const social = require('../services/social');
const songs = require('../services/songs');
const playlists = require('../services/playlists');
module.exports = function socialRoutes(csrfProtection) {
const router = express.Router();
router.use(requireUser);
router.post('/songs/:id/like', csrfProtection, (req, res, next) => {
try {
const song = songs.findById(Number(req.params.id));
if (!song) return res.status(404).json({ error: 'Not found' });
res.json(social.toggleSongLike(req.session.user.id, song.id));
} catch (err) { next(err); }
});
router.post('/songs/:id/favorite', csrfProtection, (req, res, next) => {
try {
const song = songs.findById(Number(req.params.id));
if (!song) return res.status(404).json({ error: 'Not found' });
res.json(social.toggleSongFavorite(req.session.user.id, song.id));
} catch (err) { next(err); }
});
router.post('/playlists/:id/like', csrfProtection, (req, res, next) => {
try {
const playlist = playlists.findById(Number(req.params.id));
if (!playlist) return res.status(404).json({ error: 'Not found' });
res.json(social.togglePlaylistLike(req.session.user.id, playlist.id));
} catch (err) { next(err); }
});
router.post('/playlists/:id/favorite', csrfProtection, (req, res, next) => {
try {
const playlist = playlists.findById(Number(req.params.id));
if (!playlist) return res.status(404).json({ error: 'Not found' });
res.json(social.togglePlaylistFavorite(req.session.user.id, playlist.id));
} catch (err) { next(err); }
});
return router;
};

78
src/services/social.js Normal file
View File

@ -0,0 +1,78 @@
const { db } = require('../../db');
// ── Song statements ──────────────────────────────────────────────────────────
const songLikeCountStmt = db.prepare('SELECT COUNT(*) AS c FROM song_likes WHERE song_id = ?');
const songFavCountStmt = db.prepare('SELECT COUNT(*) AS c FROM song_favorites WHERE song_id = ?');
const songUserLikedStmt = db.prepare('SELECT 1 FROM song_likes WHERE user_id = ? AND song_id = ?');
const songUserFavoritedStmt = db.prepare('SELECT 1 FROM song_favorites WHERE user_id = ? AND song_id = ?');
const insertSongLike = db.prepare('INSERT OR IGNORE INTO song_likes (user_id, song_id) VALUES (?, ?)');
const deleteSongLike = db.prepare('DELETE FROM song_likes WHERE user_id = ? AND song_id = ?');
const insertSongFav = db.prepare('INSERT OR IGNORE INTO song_favorites (user_id, song_id) VALUES (?, ?)');
const deleteSongFav = db.prepare('DELETE FROM song_favorites WHERE user_id = ? AND song_id = ?');
// ── Playlist statements ──────────────────────────────────────────────────────
const plLikeCountStmt = db.prepare('SELECT COUNT(*) AS c FROM playlist_likes WHERE playlist_id = ?');
const plFavCountStmt = db.prepare('SELECT COUNT(*) AS c FROM playlist_favorites WHERE playlist_id = ?');
const plUserLikedStmt = db.prepare('SELECT 1 FROM playlist_likes WHERE user_id = ? AND playlist_id = ?');
const plUserFavoritedStmt = db.prepare('SELECT 1 FROM playlist_favorites WHERE user_id = ? AND playlist_id = ?');
const insertPlLike = db.prepare('INSERT OR IGNORE INTO playlist_likes (user_id, playlist_id) VALUES (?, ?)');
const deletePlLike = db.prepare('DELETE FROM playlist_likes WHERE user_id = ? AND playlist_id = ?');
const insertPlFav = db.prepare('INSERT OR IGNORE INTO playlist_favorites (user_id, playlist_id) VALUES (?, ?)');
const deletePlFav = db.prepare('DELETE FROM playlist_favorites WHERE user_id = ? AND playlist_id = ?');
// ── Toggle functions ─────────────────────────────────────────────────────────
function toggleSongLike(userId, songId) {
const exists = songUserLikedStmt.get(userId, songId);
if (exists) deleteSongLike.run(userId, songId);
else insertSongLike.run(userId, songId);
return { liked: !exists, count: songLikeCountStmt.get(songId).c };
}
function toggleSongFavorite(userId, songId) {
const exists = songUserFavoritedStmt.get(userId, songId);
if (exists) deleteSongFav.run(userId, songId);
else insertSongFav.run(userId, songId);
return { favorited: !exists, count: songFavCountStmt.get(songId).c };
}
function togglePlaylistLike(userId, playlistId) {
const exists = plUserLikedStmt.get(userId, playlistId);
if (exists) deletePlLike.run(userId, playlistId);
else insertPlLike.run(userId, playlistId);
return { liked: !exists, count: plLikeCountStmt.get(playlistId).c };
}
function togglePlaylistFavorite(userId, playlistId) {
const exists = plUserFavoritedStmt.get(userId, playlistId);
if (exists) deletePlFav.run(userId, playlistId);
else insertPlFav.run(userId, playlistId);
return { favorited: !exists, count: plFavCountStmt.get(playlistId).c };
}
// ── Enrichment helpers ───────────────────────────────────────────────────────
// Mutates each item in-place, adding likeCount/favoriteCount/userLiked/userFavorited.
function enrichSongs(songs, userId = null) {
for (const s of songs) {
s.likeCount = songLikeCountStmt.get(s.id).c;
s.favoriteCount = songFavCountStmt.get(s.id).c;
s.userLiked = userId ? !!songUserLikedStmt.get(userId, s.id) : false;
s.userFavorited = userId ? !!songUserFavoritedStmt.get(userId, s.id) : false;
}
return songs;
}
function enrichPlaylists(playlists, userId = null) {
for (const p of playlists) {
p.likeCount = plLikeCountStmt.get(p.id).c;
p.favoriteCount = plFavCountStmt.get(p.id).c;
p.userLiked = userId ? !!plUserLikedStmt.get(userId, p.id) : false;
p.userFavorited = userId ? !!plUserFavoritedStmt.get(userId, p.id) : false;
}
return playlists;
}
module.exports = {
toggleSongLike, toggleSongFavorite,
togglePlaylistLike, togglePlaylistFavorite,
enrichSongs, enrichPlaylists,
};

View File

@ -3,6 +3,7 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1"> <meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="csrf-token" content="<%= csrfToken %>">
<title><%= typeof title !== 'undefined' ? title : siteName %></title> <title><%= typeof title !== 'undefined' ? title : siteName %></title>
<link rel="preload" href="/static/vendor/fonts/Orbitron.woff2" as="font" type="font/woff2" crossorigin> <link rel="preload" href="/static/vendor/fonts/Orbitron.woff2" as="font" type="font/woff2" crossorigin>
<link rel="preload" href="/static/vendor/fonts/Inter.woff2" as="font" type="font/woff2" crossorigin> <link rel="preload" href="/static/vendor/fonts/Inter.woff2" as="font" type="font/woff2" crossorigin>
@ -34,6 +35,7 @@
<script src="/static/js/navigate.js" defer></script> <script src="/static/js/navigate.js" defer></script>
<script src="/static/js/player.js" defer></script> <script src="/static/js/player.js" defer></script>
<script src="/static/js/social.js" defer></script>
<script type="module" src="/static/js/bg-grid.js"></script> <script type="module" src="/static/js/bg-grid.js"></script>
<script type="module" src="/static/js/player-viz.js"></script> <script type="module" src="/static/js/player-viz.js"></script>
<script type="module" src="/static/js/boot-sequence.js"></script> <script type="module" src="/static/js/boot-sequence.js"></script>

View File

@ -10,6 +10,16 @@
<audio id="player-audio" preload="metadata" controls crossorigin="anonymous"></audio> <audio id="player-audio" preload="metadata" controls crossorigin="anonymous"></audio>
<div class="player-queue"> <div class="player-queue">
<span id="player-queue-info" class="muted"></span> <span id="player-queue-info" class="muted"></span>
<button id="player-like" type="button"
class="btn-social" title="Like"
data-social-action="like" data-social-type="song" data-social-id=""
<% if (!user) { %>data-require-login<% } %>
aria-pressed="false" disabled>👍 <span class="social-count">0</span></button>
<button id="player-favorite" type="button"
class="btn-social" title="Favorite"
data-social-action="favorite" data-social-type="song" data-social-id=""
<% if (!user) { %>data-require-login<% } %>
aria-pressed="false" disabled>⭐ <span class="social-count">0</span></button>
<button id="player-repeat" type="button" title="Repeat" aria-pressed="false">🔁</button> <button id="player-repeat" type="button" title="Repeat" aria-pressed="false">🔁</button>
<button id="player-next" type="button" title="Next">⏭</button> <button id="player-next" type="button" title="Next">⏭</button>
</div> </div>

View File

@ -16,8 +16,22 @@
<% if (s.durationSeconds) { %><%= Math.floor(s.durationSeconds/60) %>:<%= String(s.durationSeconds % 60).padStart(2,'0') %><% } %> <% if (s.durationSeconds) { %><%= Math.floor(s.durationSeconds/60) %>:<%= String(s.durationSeconds % 60).padStart(2,'0') %><% } %>
</div> </div>
<span class="eq-bars" aria-hidden="true"><span></span><span></span><span></span></span> <span class="eq-bars" aria-hidden="true"><span></span><span></span><span></span></span>
<div class="song-social">
<button type="button"
class="btn-social<%= s.userLiked ? ' active' : '' %>"
data-social-action="like" data-social-type="song" data-social-id="<%= s.id %>"
<% if (!user) { %>data-require-login<% } %>
aria-pressed="<%= s.userLiked ? 'true' : 'false' %>"
title="Like">👍 <span class="social-count"><%= s.likeCount || 0 %></span></button>
<button type="button"
class="btn-social<%= s.userFavorited ? ' active' : '' %>"
data-social-action="favorite" data-social-type="song" data-social-id="<%= s.id %>"
<% if (!user) { %>data-require-login<% } %>
aria-pressed="<%= s.userFavorited ? 'true' : 'false' %>"
title="Favorite">⭐ <span class="social-count"><%= s.favoriteCount || 0 %></span></button>
</div>
<button type="button" class="btn-play" <button type="button" class="btn-play"
data-play-song="<%= JSON.stringify({ id: s.id, title: s.title, artist: s.artist, cover: s.coverPath ? '/media/' + s.coverPath : null }) %>"> data-play-song="<%= JSON.stringify({ id: s.id, title: s.title, artist: s.artist, cover: s.coverPath ? '/media/' + s.coverPath : null, userLiked: !!s.userLiked, userFavorited: !!s.userFavorited, likeCount: s.likeCount || 0, favoriteCount: s.favoriteCount || 0 }) %>">
▶ Play ▶ Play
</button> </button>
</li> </li>

View File

@ -23,6 +23,20 @@
</div> </div>
<% if (p.description) { %><div class="muted small"><%= p.description %></div><% } %> <% if (p.description) { %><div class="muted small"><%= p.description %></div><% } %>
<% if (p.createdByName) { %><div class="card-creator">Created by:<br><%= p.createdByName %></div><% } %> <% if (p.createdByName) { %><div class="card-creator">Created by:<br><%= p.createdByName %></div><% } %>
<div class="card-social">
<button type="button"
class="btn-social<%= p.userLiked ? ' active' : '' %>"
data-social-action="like" data-social-type="playlist" data-social-id="<%= p.id %>"
<% if (!user) { %>data-require-login<% } %>
aria-pressed="<%= p.userLiked ? 'true' : 'false' %>"
title="Like">👍 <span class="social-count"><%= p.likeCount || 0 %></span></button>
<button type="button"
class="btn-social<%= p.userFavorited ? ' active' : '' %>"
data-social-action="favorite" data-social-type="playlist" data-social-id="<%= p.id %>"
<% if (!user) { %>data-require-login<% } %>
aria-pressed="<%= p.userFavorited ? 'true' : 'false' %>"
title="Favorite">⭐ <span class="social-count"><%= p.favoriteCount || 0 %></span></button>
</div>
</div> </div>
</a> </a>
<% }) %> <% }) %>

View File

@ -9,13 +9,27 @@
<% if (playlist.createdByName) { %><p class="card-creator">Created by: <%= playlist.createdByName %></p><% } %> <% if (playlist.createdByName) { %><p class="card-creator">Created by: <%= playlist.createdByName %></p><% } %>
<% if (playlist.description) { %><p class="muted"><%= playlist.description %></p><% } %> <% if (playlist.description) { %><p class="muted"><%= playlist.description %></p><% } %>
<p class="muted small"><%= tracks.length %> track<%= tracks.length === 1 ? '' : 's' %></p> <p class="muted small"><%= tracks.length %> track<%= tracks.length === 1 ? '' : 's' %></p>
<div class="social-row">
<button type="button"
class="btn-social<%= playlist.userLiked ? ' active' : '' %>"
data-social-action="like" data-social-type="playlist" data-social-id="<%= playlist.id %>"
<% if (!user) { %>data-require-login<% } %>
aria-pressed="<%= playlist.userLiked ? 'true' : 'false' %>"
title="Like">👍 <span class="social-count"><%= playlist.likeCount || 0 %></span></button>
<button type="button"
class="btn-social<%= playlist.userFavorited ? ' active' : '' %>"
data-social-action="favorite" data-social-type="playlist" data-social-id="<%= playlist.id %>"
<% if (!user) { %>data-require-login<% } %>
aria-pressed="<%= playlist.userFavorited ? 'true' : 'false' %>"
title="Favorite">⭐ <span class="social-count"><%= playlist.favoriteCount || 0 %></span></button>
</div>
<button type="button" class="btn-primary" <button type="button" class="btn-primary"
id="play-playlist-btn" id="play-playlist-btn"
data-play-playlist="<%= JSON.stringify(tracks.map((t) => ({ id: t.id, title: t.title, artist: t.artist, cover: t.coverPath ? '/media/' + t.coverPath : null }))) %>"> data-play-playlist="<%= JSON.stringify(tracks.map((t) => ({ id: t.id, title: t.title, artist: t.artist, cover: t.coverPath ? '/media/' + t.coverPath : null, userLiked: !!t.userLiked, userFavorited: !!t.userFavorited, likeCount: t.likeCount || 0, favoriteCount: t.favoriteCount || 0 }))) %>">
▶ Play all ▶ Play all
</button> </button>
<button type="button" class="btn-primary" <button type="button" class="btn-primary"
data-shuffle-playlist="<%= JSON.stringify(tracks.map((t) => ({ id: t.id, title: t.title, artist: t.artist, cover: t.coverPath ? '/media/' + t.coverPath : null }))) %>"> data-shuffle-playlist="<%= JSON.stringify(tracks.map((t) => ({ id: t.id, title: t.title, artist: t.artist, cover: t.coverPath ? '/media/' + t.coverPath : null, userLiked: !!t.userLiked, userFavorited: !!t.userFavorited, likeCount: t.likeCount || 0, favoriteCount: t.favoriteCount || 0 }))) %>">
⇄ Shuffle all ⇄ Shuffle all
</button> </button>
<a href="/playlists" class="btn-link">← Back</a> <a href="/playlists" class="btn-link">← Back</a>

View File

@ -17,6 +17,20 @@
</div> </div>
<% if (p.description) { %><div class="muted small"><%= p.description %></div><% } %> <% if (p.description) { %><div class="muted small"><%= p.description %></div><% } %>
<% if (p.createdByName) { %><div class="card-creator">Created by:<br><%= p.createdByName %></div><% } %> <% if (p.createdByName) { %><div class="card-creator">Created by:<br><%= p.createdByName %></div><% } %>
<div class="card-social">
<button type="button"
class="btn-social<%= p.userLiked ? ' active' : '' %>"
data-social-action="like" data-social-type="playlist" data-social-id="<%= p.id %>"
<% if (!user) { %>data-require-login<% } %>
aria-pressed="<%= p.userLiked ? 'true' : 'false' %>"
title="Like">👍 <span class="social-count"><%= p.likeCount || 0 %></span></button>
<button type="button"
class="btn-social<%= p.userFavorited ? ' active' : '' %>"
data-social-action="favorite" data-social-type="playlist" data-social-id="<%= p.id %>"
<% if (!user) { %>data-require-login<% } %>
aria-pressed="<%= p.userFavorited ? 'true' : 'false' %>"
title="Favorite">⭐ <span class="social-count"><%= p.favoriteCount || 0 %></span></button>
</div>
</div> </div>
</a> </a>
<% }) %> <% }) %>

View File

@ -11,13 +11,27 @@
</h1> </h1>
<p class="muted"><%= song.artist %><% if (song.album) { %> · <%= song.album %><% } %></p> <p class="muted"><%= song.artist %><% if (song.album) { %> · <%= song.album %><% } %></p>
<% if (song.uploadedByName) { %><p class="card-creator">Added by: <%= song.uploadedByName %></p><% } %> <% if (song.uploadedByName) { %><p class="card-creator">Added by: <%= song.uploadedByName %></p><% } %>
<div class="social-row">
<button type="button"
class="btn-social<%= song.userLiked ? ' active' : '' %>"
data-social-action="like" data-social-type="song" data-social-id="<%= song.id %>"
<% if (!user) { %>data-require-login<% } %>
aria-pressed="<%= song.userLiked ? 'true' : 'false' %>"
title="Like">👍 <span class="social-count"><%= song.likeCount || 0 %></span></button>
<button type="button"
class="btn-social<%= song.userFavorited ? ' active' : '' %>"
data-social-action="favorite" data-social-type="song" data-social-id="<%= song.id %>"
<% if (!user) { %>data-require-login<% } %>
aria-pressed="<%= song.userFavorited ? 'true' : 'false' %>"
title="Favorite">⭐ <span class="social-count"><%= song.favoriteCount || 0 %></span></button>
</div>
<p class="muted small"> <p class="muted small">
<% if (song.genre) { %><%= song.genre %> · <% } %> <% if (song.genre) { %><%= song.genre %> · <% } %>
<% if (song.year) { %><%= song.year %> · <% } %> <% if (song.year) { %><%= song.year %> · <% } %>
<% if (song.durationSeconds) { %><%= Math.floor(song.durationSeconds/60) %>:<%= String(song.durationSeconds%60).padStart(2,'0') %><% } %> <% if (song.durationSeconds) { %><%= Math.floor(song.durationSeconds/60) %>:<%= String(song.durationSeconds%60).padStart(2,'0') %><% } %>
</p> </p>
<button type="button" class="btn-play btn-primary" <button type="button" class="btn-play btn-primary"
data-play-song="<%= JSON.stringify({ id: song.id, title: song.title, artist: song.artist, cover: song.coverPath ? '/media/' + song.coverPath : null }) %>"> data-play-song="<%= JSON.stringify({ id: song.id, title: song.title, artist: song.artist, cover: song.coverPath ? '/media/' + song.coverPath : null, userLiked: !!song.userLiked, userFavorited: !!song.userFavorited, likeCount: song.likeCount || 0, favoriteCount: song.favoriteCount || 0 }) %>">
▶ Play ▶ Play
</button> </button>
<a href="/songs" class="btn-link">← Back to songs</a> <a href="/songs" class="btn-link">← Back to songs</a>