feat: add playlist top genres and similar playlists
- Add `top_genres` column to playlists table to store the top 3 genres - Implement `computeTopGenres`, `updateTopGenres`, and `reindexAllGenres` in playlists service - Add "Similar Playlists" section to public playlist view based on genre overlap - Display genre tags on playlist details page - Add admin panel route to re-index all playlist genres - Update social tooltip positioning to use fixed positioning for better visibility - Add CSS styles for genre tags and track numbers - Increment service worker cache version
This commit is contained in:
parent
b0d9c45265
commit
cb2ff2492f
|
|
@ -0,0 +1 @@
|
||||||
|
ALTER TABLE playlists ADD COLUMN top_genres TEXT;
|
||||||
|
|
@ -395,6 +395,7 @@ button.linklike.danger:hover { color: #fff; text-shadow: 0 0 6px var(--danger);
|
||||||
.badge-vip { color: #39ff14; border: 1px solid #39ff14; box-shadow: 0 0 5px rgba(57,255,20,0.5); background: rgba(57,255,20,0.08); }
|
.badge-vip { color: #39ff14; border: 1px solid #39ff14; box-shadow: 0 0 5px rgba(57,255,20,0.5); background: rgba(57,255,20,0.08); }
|
||||||
.badge-private { color: var(--neon-magenta); border: 1px solid var(--neon-magenta); box-shadow: 0 0 5px rgba(255,43,214,0.4); }
|
.badge-private { color: var(--neon-magenta); border: 1px solid var(--neon-magenta); box-shadow: 0 0 5px rgba(255,43,214,0.4); }
|
||||||
.badge-nsfw { color: #ff3333; border: 1px solid #ff3333; box-shadow: 0 0 5px rgba(255,51,51,0.5); background: rgba(255,51,51,0.08); cursor: help; }
|
.badge-nsfw { color: #ff3333; border: 1px solid #ff3333; box-shadow: 0 0 5px rgba(255,51,51,0.5); background: rgba(255,51,51,0.08); cursor: help; }
|
||||||
|
.genre-tag { display: inline-block; font-size: 0.68rem; padding: 1px 7px; border-radius: 20px; background: rgba(143,91,255,0.12); border: 1px solid rgba(143,91,255,0.4); color: var(--muted); letter-spacing: 0.03em; }
|
||||||
|
|
||||||
/* ---------- Visibility radio fieldset ---------- */
|
/* ---------- Visibility radio fieldset ---------- */
|
||||||
.visibility-field {
|
.visibility-field {
|
||||||
|
|
@ -423,6 +424,7 @@ button.linklike.danger:hover { color: #fff; text-shadow: 0 0 6px var(--danger);
|
||||||
transition: background .15s;
|
transition: background .15s;
|
||||||
}
|
}
|
||||||
.song-row:hover { background: rgba(143, 91, 255, 0.08); }
|
.song-row:hover { background: rgba(143, 91, 255, 0.08); }
|
||||||
|
.track-num { min-width: 1.6rem; text-align: right; font-size: 0.75rem; color: var(--muted); font-variant-numeric: tabular-nums; flex-shrink: 0; }
|
||||||
.song-cover { width: 44px; height: 44px; border-radius: 4px; object-fit: cover; border: 1px solid var(--border); }
|
.song-cover { width: 44px; height: 44px; border-radius: 4px; object-fit: cover; border: 1px solid var(--border); }
|
||||||
.song-cover.cover-fallback { font-size: 1.25rem; }
|
.song-cover.cover-fallback { font-size: 1.25rem; }
|
||||||
.song-main { flex: 1; min-width: 0; }
|
.song-main { flex: 1; min-width: 0; }
|
||||||
|
|
@ -777,17 +779,15 @@ a.detail-creator-card:hover {
|
||||||
|
|
||||||
/* ---------- Social hover tooltip ---------- */
|
/* ---------- Social hover tooltip ---------- */
|
||||||
.social-tooltip {
|
.social-tooltip {
|
||||||
position: absolute;
|
position: fixed;
|
||||||
z-index: 800;
|
z-index: 800;
|
||||||
top: calc(100% + 6px);
|
|
||||||
left: 0;
|
|
||||||
min-width: 120px;
|
min-width: 120px;
|
||||||
max-width: 220px;
|
max-width: 220px;
|
||||||
padding: 0.45rem 0.65rem;
|
padding: 0.45rem 0.65rem;
|
||||||
background: var(--card-bg);
|
background: #0c0e1a;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
box-shadow: 0 4px 16px rgba(0,0,0,0.5);
|
box-shadow: 0 4px 20px rgba(0,0,0,0.85);
|
||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
|
@ -799,7 +799,6 @@ a.detail-creator-card:hover {
|
||||||
.social-tooltip strong { display: block; color: var(--fg); margin-bottom: 0.2rem; font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.05em; }
|
.social-tooltip strong { display: block; color: var(--fg); margin-bottom: 0.2rem; font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.05em; }
|
||||||
.social-tooltip ul { margin: 0; padding: 0; list-style: none; }
|
.social-tooltip ul { margin: 0; padding: 0; list-style: none; }
|
||||||
.social-tooltip ul li { overflow: hidden; text-overflow: ellipsis; }
|
.social-tooltip ul li { overflow: hidden; text-overflow: ellipsis; }
|
||||||
.btn-social { position: relative; }
|
|
||||||
|
|
||||||
/* ---------- Lightbox ---------- */
|
/* ---------- Lightbox ---------- */
|
||||||
.lb-trigger {
|
.lb-trigger {
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@
|
||||||
|
|
||||||
function showTooltip(btn, data) {
|
function showTooltip(btn, data) {
|
||||||
removeTooltip();
|
removeTooltip();
|
||||||
const action = btn.dataset.socialAction; // 'like' or 'favorite'
|
const action = btn.dataset.socialAction;
|
||||||
const names = action === 'like' ? data.likers : data.favoriters;
|
const names = action === 'like' ? data.likers : data.favoriters;
|
||||||
if (!names || names.length === 0) return;
|
if (!names || names.length === 0) return;
|
||||||
|
|
||||||
|
|
@ -22,8 +22,16 @@
|
||||||
const label = action === 'like' ? 'Liked by' : 'Favorited by';
|
const label = action === 'like' ? 'Liked by' : 'Favorited by';
|
||||||
const ul = names.map(n => `<li>${n.replace(/</g, '<')}</li>`).join('');
|
const ul = names.map(n => `<li>${n.replace(/</g, '<')}</li>`).join('');
|
||||||
tip.innerHTML = `<strong>${label}</strong><ul>${ul}</ul>`;
|
tip.innerHTML = `<strong>${label}</strong><ul>${ul}</ul>`;
|
||||||
btn.appendChild(tip);
|
|
||||||
|
// Attach to body so overflow:hidden on ancestor cards can't clip it.
|
||||||
|
document.body.appendChild(tip);
|
||||||
activeTooltip = tip;
|
activeTooltip = tip;
|
||||||
|
|
||||||
|
const rect = btn.getBoundingClientRect();
|
||||||
|
tip.style.position = 'fixed';
|
||||||
|
tip.style.top = (rect.bottom + 6) + 'px';
|
||||||
|
tip.style.left = rect.left + 'px';
|
||||||
|
|
||||||
requestAnimationFrame(() => tip.classList.add('visible'));
|
requestAnimationFrame(() => tip.classList.add('visible'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
const CACHE = 'bri-tunes-v6';
|
const CACHE = 'bri-tunes-v7';
|
||||||
const STATIC_PRECACHE = [
|
const STATIC_PRECACHE = [
|
||||||
'/',
|
'/',
|
||||||
'/static/css/app.css',
|
'/static/css/app.css',
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,9 @@ const express = require('express');
|
||||||
const multer = require('multer');
|
const multer = require('multer');
|
||||||
|
|
||||||
const { requireAdmin } = require('../middleware/auth');
|
const { requireAdmin } = require('../middleware/auth');
|
||||||
const users = require('../services/users');
|
const users = require('../services/users');
|
||||||
const settings = require('../services/settings');
|
const settings = require('../services/settings');
|
||||||
|
const playlists = require('../services/playlists');
|
||||||
|
|
||||||
const mediaDir = process.env.MEDIA_DIR || path.join(__dirname, '..', '..', 'media');
|
const mediaDir = process.env.MEDIA_DIR || path.join(__dirname, '..', '..', 'media');
|
||||||
|
|
||||||
|
|
@ -195,5 +196,13 @@ module.exports = function adminPanelRoutes(csrfProtection) {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.post('/reindex-playlist-genres', csrfProtection, (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const count = playlists.reindexAllGenres();
|
||||||
|
req.flash('success', `Re-indexed genres for ${count} playlist${count === 1 ? '' : 's'}.`);
|
||||||
|
res.redirect('/admin#playlists');
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
return router;
|
return router;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -421,6 +421,7 @@ module.exports = function adminRoutes(csrfProtection) {
|
||||||
playlists.setVisibility(created.id, song.visibility);
|
playlists.setVisibility(created.id, song.visibility);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
playlists.updateTopGenres(created.id);
|
||||||
req.flash('success', 'Playlist created.');
|
req.flash('success', 'Playlist created.');
|
||||||
res.redirect(`/mymusic/playlists/${created.id}/edit`);
|
res.redirect(`/mymusic/playlists/${created.id}/edit`);
|
||||||
} catch (err) { next(err); }
|
} catch (err) { next(err); }
|
||||||
|
|
@ -482,6 +483,7 @@ module.exports = function adminRoutes(csrfProtection) {
|
||||||
console.warn('[admin] failed to process playlist cover on edit:', err.message);
|
console.warn('[admin] failed to process playlist cover on edit:', err.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
playlists.updateTopGenres(id);
|
||||||
req.flash('success', 'Playlist updated.');
|
req.flash('success', 'Playlist updated.');
|
||||||
res.redirect(`/mymusic/playlists/${id}/edit`);
|
res.redirect(`/mymusic/playlists/${id}/edit`);
|
||||||
} catch (err) { next(err); }
|
} catch (err) { next(err); }
|
||||||
|
|
@ -500,6 +502,7 @@ module.exports = function adminRoutes(csrfProtection) {
|
||||||
if (VISIBILITY_RANK[song.visibility] > VISIBILITY_RANK[playlist.visibility]) {
|
if (VISIBILITY_RANK[song.visibility] > VISIBILITY_RANK[playlist.visibility]) {
|
||||||
playlists.setVisibility(id, song.visibility);
|
playlists.setVisibility(id, song.visibility);
|
||||||
}
|
}
|
||||||
|
playlists.updateTopGenres(id);
|
||||||
res.redirect(`/mymusic/playlists/${id}/edit`);
|
res.redirect(`/mymusic/playlists/${id}/edit`);
|
||||||
} catch (err) { next(err); }
|
} catch (err) { next(err); }
|
||||||
});
|
});
|
||||||
|
|
@ -512,6 +515,7 @@ module.exports = function adminRoutes(csrfProtection) {
|
||||||
if (!playlist) return next();
|
if (!playlist) return next();
|
||||||
if (!canManage(playlist, req.session.user)) return forbidden(next);
|
if (!canManage(playlist, req.session.user)) return forbidden(next);
|
||||||
playlists.removeTrack(id, songId);
|
playlists.removeTrack(id, songId);
|
||||||
|
playlists.updateTopGenres(id);
|
||||||
res.redirect(`/mymusic/playlists/${id}/edit`);
|
res.redirect(`/mymusic/playlists/${id}/edit`);
|
||||||
} catch (err) { next(err); }
|
} catch (err) { next(err); }
|
||||||
});
|
});
|
||||||
|
|
@ -525,6 +529,7 @@ module.exports = function adminRoutes(csrfProtection) {
|
||||||
const order = String(req.body.order || '')
|
const order = String(req.body.order || '')
|
||||||
.split(',').map((x) => Number(x.trim())).filter(Number.isFinite);
|
.split(',').map((x) => Number(x.trim())).filter(Number.isFinite);
|
||||||
playlists.setOrder(id, order);
|
playlists.setOrder(id, order);
|
||||||
|
playlists.updateTopGenres(id);
|
||||||
res.redirect(`/mymusic/playlists/${id}/edit`);
|
res.redirect(`/mymusic/playlists/${id}/edit`);
|
||||||
} catch (err) { next(err); }
|
} catch (err) { next(err); }
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -129,13 +129,15 @@ router.get('/playlists/:slug', (req, res, next) => {
|
||||||
const tracks = playlists.getTracks(playlist.id).filter(t => canView(t.visibility, t.uploadedBy, userId, isVip) && (!t.isNsfw || !!userId));
|
const tracks = playlists.getTracks(playlist.id).filter(t => canView(t.visibility, t.uploadedBy, userId, isVip) && (!t.isNsfw || !!userId));
|
||||||
social.enrichSongs(tracks, userId);
|
social.enrichSongs(tracks, userId);
|
||||||
const creator = playlist.createdBy ? users.findById(playlist.createdBy) : null;
|
const creator = playlist.createdBy ? users.findById(playlist.createdBy) : null;
|
||||||
|
const similar = playlists.getSimilar(playlist.id, !!userId, isVip);
|
||||||
|
social.enrichPlaylists(similar, userId);
|
||||||
const og = {
|
const og = {
|
||||||
title: `${res.locals.siteName} Playlist: ${playlist.title}`,
|
title: `${res.locals.siteName} Playlist: ${playlist.title}`,
|
||||||
description: playlist.description || null,
|
description: playlist.description || null,
|
||||||
image: playlist.coverPath ? `${res.locals.appBaseUrl}/media/${playlist.coverPath}` : null,
|
image: playlist.coverPath ? `${res.locals.appBaseUrl}/media/${playlist.coverPath}` : null,
|
||||||
url: `${res.locals.appBaseUrl}/playlists/${playlist.slug}`,
|
url: `${res.locals.appBaseUrl}/playlists/${playlist.slug}`,
|
||||||
};
|
};
|
||||||
res.render('public/playlist', { title: playlist.title, playlist, tracks, creator, og });
|
res.render('public/playlist', { title: playlist.title, playlist, tracks, creator, similar, og });
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/profiles', (req, res) => {
|
router.get('/profiles', (req, res) => {
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ const updateStmt = db.prepare(`
|
||||||
`);
|
`);
|
||||||
const updateCoverStmt = db.prepare('UPDATE playlists SET cover_path = ? WHERE id = ?');
|
const updateCoverStmt = db.prepare('UPDATE playlists SET cover_path = ? WHERE id = ?');
|
||||||
const setVisibilityStmt = db.prepare('UPDATE playlists SET visibility = ? WHERE id = ?');
|
const setVisibilityStmt = db.prepare('UPDATE playlists SET visibility = ? WHERE id = ?');
|
||||||
|
const setTopGenresStmt = db.prepare('UPDATE playlists SET top_genres = ? WHERE id = ?');
|
||||||
const deleteStmt = db.prepare('DELETE FROM playlists WHERE id = ?');
|
const deleteStmt = db.prepare('DELETE FROM playlists WHERE id = ?');
|
||||||
const findByIdStmt = db.prepare('SELECT * FROM playlists WHERE id = ?');
|
const findByIdStmt = db.prepare('SELECT * FROM playlists WHERE id = ?');
|
||||||
|
|
||||||
|
|
@ -115,6 +116,13 @@ const effectiveVisibilityStmt = db.prepare(`
|
||||||
FROM playlists p WHERE p.id = ?
|
FROM playlists p WHERE p.id = ?
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
const trackGenresStmt = db.prepare(`
|
||||||
|
SELECT s.genre FROM playlist_songs ps
|
||||||
|
JOIN songs s ON s.id = ps.song_id
|
||||||
|
WHERE ps.playlist_id = ? AND s.genre IS NOT NULL AND s.genre != ''
|
||||||
|
`);
|
||||||
|
const allPlaylistIdsStmt = db.prepare('SELECT id FROM playlists');
|
||||||
|
|
||||||
const tracksStmt = db.prepare(`
|
const tracksStmt = db.prepare(`
|
||||||
SELECT s.*, ps.position FROM playlist_songs ps
|
SELECT s.*, ps.position FROM playlist_songs ps
|
||||||
JOIN songs s ON s.id = ps.song_id
|
JOIN songs s ON s.id = ps.song_id
|
||||||
|
|
@ -165,6 +173,8 @@ function findBySlug(slug) {
|
||||||
|
|
||||||
function toView(row) {
|
function toView(row) {
|
||||||
if (!row) return null;
|
if (!row) return null;
|
||||||
|
let topGenres = [];
|
||||||
|
try { if (row.top_genres) topGenres = JSON.parse(row.top_genres); } catch (_) {}
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
slug: row.slug,
|
slug: row.slug,
|
||||||
|
|
@ -176,9 +186,34 @@ function toView(row) {
|
||||||
createdBy: row.created_by,
|
createdBy: row.created_by,
|
||||||
createdByName: row.created_by ? (creatorNameStmt.get(row.created_by)?.display_name || null) : null,
|
createdByName: row.created_by ? (creatorNameStmt.get(row.created_by)?.display_name || null) : null,
|
||||||
createdAt: row.created_at,
|
createdAt: row.created_at,
|
||||||
|
topGenres,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function computeTopGenres(playlistId) {
|
||||||
|
const rows = trackGenresStmt.all(playlistId);
|
||||||
|
const counts = {};
|
||||||
|
for (const row of rows) {
|
||||||
|
const key = row.genre.trim().toLowerCase();
|
||||||
|
if (key) counts[key] = (counts[key] || 0) + 1;
|
||||||
|
}
|
||||||
|
return Object.entries(counts)
|
||||||
|
.sort((a, b) => b[1] - a[1])
|
||||||
|
.slice(0, 3)
|
||||||
|
.map(([g]) => g.charAt(0).toUpperCase() + g.slice(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateTopGenres(playlistId) {
|
||||||
|
const genres = computeTopGenres(playlistId);
|
||||||
|
setTopGenresStmt.run(genres.length ? JSON.stringify(genres) : null, playlistId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function reindexAllGenres() {
|
||||||
|
const ids = allPlaylistIdsStmt.all();
|
||||||
|
for (const { id } of ids) updateTopGenres(id);
|
||||||
|
return ids.length;
|
||||||
|
}
|
||||||
|
|
||||||
function trackView(row) {
|
function trackView(row) {
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
|
|
@ -327,8 +362,31 @@ function getPlaylistsForSong(songId, loggedIn = false, isVip = false) {
|
||||||
return (isVip ? forSongVipStmt : loggedIn ? forSongUserStmt : forSongGuestStmt).all(songId).map(toView);
|
return (isVip ? forSongVipStmt : loggedIn ? forSongUserStmt : forSongGuestStmt).all(songId).map(toView);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getSimilar(playlistId, loggedIn = false, isVip = false, limit = 5) {
|
||||||
|
const source = toView(findByIdStmt.get(playlistId));
|
||||||
|
if (!source || !source.topGenres.length) return [];
|
||||||
|
|
||||||
|
const sourceGenres = source.topGenres.map(g => g.toLowerCase());
|
||||||
|
const candidates = (isVip ? listVipStmt : loggedIn ? listUserStmt : listGuestStmt).all().map(toView);
|
||||||
|
|
||||||
|
const scored = candidates
|
||||||
|
.filter(p => p.id !== playlistId && p.topGenres.length > 0)
|
||||||
|
.map(p => {
|
||||||
|
const pGenres = p.topGenres.map(g => g.toLowerCase());
|
||||||
|
const score = sourceGenres.filter(g => pGenres.includes(g)).length;
|
||||||
|
return { playlist: p, score };
|
||||||
|
})
|
||||||
|
.filter(({ score }) => score > 0)
|
||||||
|
.sort((a, b) => b.score - a.score)
|
||||||
|
.slice(0, limit)
|
||||||
|
.map(({ playlist }) => playlist);
|
||||||
|
|
||||||
|
return scored;
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
create, update, setCover, setVisibility, remove, findById, findBySlug, getEffectiveVisibility,
|
create, update, setCover, setVisibility, remove, findById, findBySlug, getEffectiveVisibility,
|
||||||
listVisible, listAll, listByUser, listPublicByUser, featured,
|
listVisible, listAll, listByUser, listPublicByUser, featured,
|
||||||
getTracks, trackCount, addTrack, removeTrack, setOrder, getPlaylistsForSong,
|
getTracks, trackCount, addTrack, removeTrack, setOrder, getPlaylistsForSong,
|
||||||
|
updateTopGenres, reindexAllGenres, getSimilar,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,19 @@
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
<!-- ── Playlists ───────────────────────────────────────────────────────── -->
|
||||||
|
<h2 id="playlists" style="margin-top:2.5rem">Playlists</h2>
|
||||||
|
<div class="form-card" style="padding:1.25rem 1.5rem;display:flex;align-items:center;gap:1rem;flex-wrap:wrap">
|
||||||
|
<div>
|
||||||
|
<strong>Re-index playlist genres</strong>
|
||||||
|
<p class="muted small" style="margin:0.25rem 0 0">Scans every playlist's tracks and recomputes the top 3 genres used for Similar Playlists matching.</p>
|
||||||
|
</div>
|
||||||
|
<form method="post" action="/admin/reindex-playlist-genres" style="margin:0;flex-shrink:0">
|
||||||
|
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
|
||||||
|
<button type="submit" class="btn-secondary">Re-index Now</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- ── Song Generation Details ────────────────────────────────────────── -->
|
<!-- ── Song Generation Details ────────────────────────────────────────── -->
|
||||||
<h2 id="generation" style="margin-top:2.5rem">Song Generation Details</h2>
|
<h2 id="generation" style="margin-top:2.5rem">Song Generation Details</h2>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,9 @@
|
||||||
</div>
|
</div>
|
||||||
<% } %>
|
<% } %>
|
||||||
<ul class="song-list<%= (typeof selectable !== 'undefined' && selectable) ? ' song-list--selectable' : '' %>">
|
<ul class="song-list<%= (typeof selectable !== 'undefined' && selectable) ? ' song-list--selectable' : '' %>">
|
||||||
<% songs.forEach((s) => { %>
|
<% songs.forEach((s, i) => { %>
|
||||||
<li class="song-row" data-song-id="<%= s.id %>">
|
<li class="song-row" data-song-id="<%= s.id %>">
|
||||||
|
<% if (typeof showNumbers !== 'undefined' && showNumbers) { %><span class="track-num"><%= i + 1 %></span><% } %>
|
||||||
<% if (typeof selectable !== 'undefined' && selectable) { %>
|
<% if (typeof selectable !== 'undefined' && selectable) { %>
|
||||||
<input type="checkbox" class="song-select" value="<%= s.id %>" aria-label="Select <%= s.title %>">
|
<input type="checkbox" class="song-select" value="<%= s.id %>" aria-label="Select <%= s.title %>">
|
||||||
<% } %>
|
<% } %>
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,12 @@
|
||||||
<% if (playlist.isNsfw) { %><span class="badge badge-nsfw" title="Warning: Parental Advisory: Explicit Lyrics">EXPLICIT</span><% } %>
|
<% if (playlist.isNsfw) { %><span class="badge badge-nsfw" title="Warning: Parental Advisory: Explicit Lyrics">EXPLICIT</span><% } %>
|
||||||
</h1>
|
</h1>
|
||||||
<% 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' %>
|
||||||
|
<% if (playlist.topGenres && playlist.topGenres.length) { %>
|
||||||
|
· <% playlist.topGenres.forEach((g, i) => { %><span class="genre-tag"><%= g %></span><% if (i < playlist.topGenres.length - 1) { %> <% } %><% }) %>
|
||||||
|
<% } %>
|
||||||
|
</p>
|
||||||
<div class="social-row">
|
<div class="social-row">
|
||||||
<button type="button"
|
<button type="button"
|
||||||
class="btn-social<%= playlist.userLiked ? ' active' : '' %>"
|
class="btn-social<%= playlist.userLiked ? ' active' : '' %>"
|
||||||
|
|
@ -171,5 +176,49 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<% if (tracks.length > 0) { %>
|
<% if (tracks.length > 0) { %>
|
||||||
<%- include('../partials/song_list', { songs: tracks }) %>
|
<%- include('../partials/song_list', { songs: tracks, showNumbers: true }) %>
|
||||||
|
<% } %>
|
||||||
|
|
||||||
|
<% if (similar && similar.length > 0) { %>
|
||||||
|
<section style="margin-top:2.5rem">
|
||||||
|
<h2 style="margin-bottom:1rem">Similar Playlists</h2>
|
||||||
|
<div class="grid">
|
||||||
|
<% similar.forEach((p) => { %>
|
||||||
|
<a class="card" href="/playlists/<%= p.slug %>" data-playlist-id="<%= p.id %>">
|
||||||
|
<% if (p.coverPath) { %>
|
||||||
|
<img src="/media/<%= p.coverPath %>" alt="" />
|
||||||
|
<% } else { %>
|
||||||
|
<div class="cover-fallback">🎧</div>
|
||||||
|
<% } %>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="card-title"><%= p.title %>
|
||||||
|
<% if (p.visibility === 'logged_in') { %><span class="badge badge-logged-in">Members</span><% } %>
|
||||||
|
<% if (p.visibility === 'vip') { %><span class="badge badge-vip">VIP</span><% } %>
|
||||||
|
</div>
|
||||||
|
<% if (p.topGenres && p.topGenres.length) { %>
|
||||||
|
<div style="margin:0.25rem 0 0.1rem"><% p.topGenres.forEach((g) => { %><span class="genre-tag"><%= g %></span> <% }) %></div>
|
||||||
|
<% } %>
|
||||||
|
<% if (p.description) { %><div class="muted small"><%= p.description %></div><% } %>
|
||||||
|
<div class="card-footer">
|
||||||
|
<% 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' %>"
|
||||||
|
>👍 <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' %>"
|
||||||
|
>⭐ <span class="social-count"><%= p.favoriteCount || 0 %></span></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
<% }) %>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
<% } %>
|
<% } %>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue