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 .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); }
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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({
|
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));
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
<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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
<% }) %>
|
<% }) %>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
<% }) %>
|
<% }) %>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue