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:
parent
a24f2fe75d
commit
f044d98afc
|
|
@ -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)
|
||||
);
|
||||
|
|
@ -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 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 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); }
|
||||
|
|
|
|||
|
|
@ -6,8 +6,25 @@
|
|||
const queueInfoEl = document.getElementById('player-queue-info');
|
||||
const nextBtn = document.getElementById('player-next');
|
||||
const repeatBtn = document.getElementById('player-repeat');
|
||||
const playerLikeBtn = document.getElementById('player-like');
|
||||
const playerFavBtn = document.getElementById('player-favorite');
|
||||
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 = {
|
||||
queue: [], // array of {id, title, artist, cover}
|
||||
index: -1,
|
||||
|
|
@ -20,6 +37,7 @@
|
|||
artistEl.textContent = '';
|
||||
coverEl.src = '/static/vendor/cover-placeholder.svg';
|
||||
queueInfoEl.textContent = '';
|
||||
updatePlayerSocial(null);
|
||||
return;
|
||||
}
|
||||
titleEl.textContent = cur.title;
|
||||
|
|
@ -28,6 +46,7 @@
|
|||
queueInfoEl.textContent = state.queue.length > 1
|
||||
? `${state.index + 1} / ${state.queue.length}`
|
||||
: '';
|
||||
updatePlayerSocial(cur);
|
||||
}
|
||||
|
||||
function highlightNowPlaying(id) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
|
@ -46,7 +46,7 @@ app.use(session({
|
|||
}));
|
||||
|
||||
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.
|
||||
|
|
@ -69,6 +69,7 @@ app.use((req, _res, next) => {
|
|||
});
|
||||
|
||||
// Routes.
|
||||
app.use('/api', require('./routes/social')(csrfSynchronisedProtection));
|
||||
app.use('/', require('./routes/public'));
|
||||
app.use('/', require('./routes/auth')(csrfSynchronisedProtection));
|
||||
app.use('/account', require('./routes/account')(csrfSynchronisedProtection));
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ const express = require('express');
|
|||
|
||||
const songs = require('../services/songs');
|
||||
const playlists = require('../services/playlists');
|
||||
const social = require('../services/social');
|
||||
|
||||
const router = express.Router();
|
||||
const mediaDir = process.env.MEDIA_DIR || path.join(__dirname, '..', '..', 'media');
|
||||
|
|
@ -36,15 +37,17 @@ function denyAccess(req, res, next, visibility) {
|
|||
|
||||
router.get('/', (req, res) => {
|
||||
const loggedIn = !!req.session.user;
|
||||
const userId = req.session.user?.id || null;
|
||||
res.render('public/home', {
|
||||
title: res.locals.siteName,
|
||||
featured: playlists.featured(5, loggedIn),
|
||||
recent: songs.recent(8, loggedIn),
|
||||
featured: social.enrichPlaylists(playlists.featured(5, loggedIn), userId),
|
||||
recent: social.enrichSongs(songs.recent(8, loggedIn), userId),
|
||||
});
|
||||
});
|
||||
|
||||
router.get('/songs', (req, res) => {
|
||||
const loggedIn = !!req.session.user;
|
||||
const userId = req.session.user?.id || null;
|
||||
const page = Math.max(1, Number(req.query.page) || 1);
|
||||
const pageSize = 25;
|
||||
const { rows, total } = songs.list({
|
||||
|
|
@ -53,6 +56,7 @@ router.get('/songs', (req, res) => {
|
|||
offset: (page - 1) * pageSize,
|
||||
loggedIn,
|
||||
});
|
||||
social.enrichSongs(rows, userId);
|
||||
res.render('public/songs', {
|
||||
title: 'Songs',
|
||||
songs: rows,
|
||||
|
|
@ -70,24 +74,30 @@ router.get('/songs/:slug', (req, res, next) => {
|
|||
if (!canView(song.visibility, song.uploadedBy, req.session.user?.id)) {
|
||||
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 });
|
||||
});
|
||||
|
||||
router.get('/playlists', (req, res) => {
|
||||
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) => {
|
||||
const playlist = playlists.findBySlug(req.params.slug);
|
||||
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).
|
||||
const effectiveVisibility = playlists.getEffectiveVisibility(playlist.id);
|
||||
if (!canView(effectiveVisibility, playlist.createdBy, userId)) {
|
||||
return denyAccess(req, res, next, effectiveVisibility);
|
||||
}
|
||||
social.enrichPlaylists([playlist], 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 });
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
@ -3,6 +3,7 @@
|
|||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<meta name="csrf-token" content="<%= csrfToken %>">
|
||||
<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/Inter.woff2" as="font" type="font/woff2" crossorigin>
|
||||
|
|
@ -34,6 +35,7 @@
|
|||
|
||||
<script src="/static/js/navigate.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/player-viz.js"></script>
|
||||
<script type="module" src="/static/js/boot-sequence.js"></script>
|
||||
|
|
|
|||
|
|
@ -10,6 +10,16 @@
|
|||
<audio id="player-audio" preload="metadata" controls crossorigin="anonymous"></audio>
|
||||
<div class="player-queue">
|
||||
<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-next" type="button" title="Next">⏭</button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -16,8 +16,22 @@
|
|||
<% if (s.durationSeconds) { %><%= Math.floor(s.durationSeconds/60) %>:<%= String(s.durationSeconds % 60).padStart(2,'0') %><% } %>
|
||||
</div>
|
||||
<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"
|
||||
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
|
||||
</button>
|
||||
</li>
|
||||
|
|
|
|||
|
|
@ -23,6 +23,20 @@
|
|||
</div>
|
||||
<% if (p.description) { %><div class="muted small"><%= p.description %></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>
|
||||
</a>
|
||||
<% }) %>
|
||||
|
|
|
|||
|
|
@ -9,13 +9,27 @@
|
|||
<% if (playlist.createdByName) { %><p class="card-creator">Created by: <%= playlist.createdByName %></p><% } %>
|
||||
<% if (playlist.description) { %><p class="muted"><%= playlist.description %></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"
|
||||
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
|
||||
</button>
|
||||
<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
|
||||
</button>
|
||||
<a href="/playlists" class="btn-link">← Back</a>
|
||||
|
|
|
|||
|
|
@ -17,6 +17,20 @@
|
|||
</div>
|
||||
<% if (p.description) { %><div class="muted small"><%= p.description %></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>
|
||||
</a>
|
||||
<% }) %>
|
||||
|
|
|
|||
|
|
@ -11,13 +11,27 @@
|
|||
</h1>
|
||||
<p class="muted"><%= song.artist %><% if (song.album) { %> · <%= song.album %><% } %></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">
|
||||
<% if (song.genre) { %><%= song.genre %> · <% } %>
|
||||
<% if (song.year) { %><%= song.year %> · <% } %>
|
||||
<% if (song.durationSeconds) { %><%= Math.floor(song.durationSeconds/60) %>:<%= String(song.durationSeconds%60).padStart(2,'0') %><% } %>
|
||||
</p>
|
||||
<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
|
||||
</button>
|
||||
<a href="/songs" class="btn-link">← Back to songs</a>
|
||||
|
|
|
|||
Loading…
Reference in New Issue