feat: add user profiles with slugs and enhance now-playing UI
- Add database migration to create unique `slug` column for users table - Implement profile routes (`/profiles`, `/profiles/:slug`) with views for: • listing verified user profiles with avatars, song/playlist counts • individual profile pages showing user's songs, playlists, liked items - Add helper functions in `users.js`, `songs.js`, `playlists.js`, and `social.js` to support public visibility filtering and profile data enrichment - Enhance now-playing overlay UI with: • vinyl sleeve animation (sleeve + record peeking behind cover) • clickable creator info to open profile page if slug exists • track row click to jump directly to a song in the queue - Update CSS for profiles grid, detail creator links, and overlay styling - Add "Profiles" link to header navigation This enables a full user profile system with vanity URLs and improves the music player experience with visual polish and direct navigation.
This commit is contained in:
parent
a4c1595eaa
commit
25d68e1dcb
|
|
@ -0,0 +1,2 @@
|
||||||
|
ALTER TABLE users ADD COLUMN slug TEXT;
|
||||||
|
CREATE UNIQUE INDEX users_slug_unique ON users (slug);
|
||||||
|
|
@ -387,6 +387,14 @@ button.linklike.danger:hover { color: #fff; text-shadow: 0 0 6px var(--danger);
|
||||||
display: flex; flex-direction: column; align-items: center;
|
display: flex; flex-direction: column; align-items: center;
|
||||||
gap: 0.35rem; flex-shrink: 0; text-align: center;
|
gap: 0.35rem; flex-shrink: 0; text-align: center;
|
||||||
min-width: 80px;
|
min-width: 80px;
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.5rem;
|
||||||
|
transition: background 0.15s ease, box-shadow 0.15s ease;
|
||||||
|
}
|
||||||
|
a.detail-creator-card:hover {
|
||||||
|
background: var(--bg-panel);
|
||||||
|
box-shadow: 0 0 0 1px var(--neon-cyan), 0 0 10px rgba(0, 255, 255, 0.2);
|
||||||
}
|
}
|
||||||
.detail-creator-avatar {
|
.detail-creator-avatar {
|
||||||
width: 72px; height: 72px;
|
width: 72px; height: 72px;
|
||||||
|
|
@ -581,7 +589,7 @@ button.linklike.danger:hover { color: #fff; text-shadow: 0 0 6px var(--danger);
|
||||||
display: none;
|
display: none;
|
||||||
position: fixed; inset: 0;
|
position: fixed; inset: 0;
|
||||||
background: rgba(0,0,0,0);
|
background: rgba(0,0,0,0);
|
||||||
z-index: 2000;
|
z-index: 3000;
|
||||||
align-items: center; justify-content: center;
|
align-items: center; justify-content: center;
|
||||||
cursor: zoom-out;
|
cursor: zoom-out;
|
||||||
}
|
}
|
||||||
|
|
@ -656,7 +664,7 @@ button.linklike.danger:hover { color: #fff; text-shadow: 0 0 6px var(--danger);
|
||||||
box-shadow: 0 0 0 1px var(--border), 0 0 40px rgba(143,91,255,0.45);
|
box-shadow: 0 0 0 1px var(--border), 0 0 40px rgba(143,91,255,0.45);
|
||||||
width: min(940px, 92vw);
|
width: min(940px, 92vw);
|
||||||
height: min(640px, 85vh);
|
height: min(640px, 85vh);
|
||||||
overflow: hidden;
|
overflow: visible;
|
||||||
}
|
}
|
||||||
#np-overlay.np-open #np-modal { animation: lb-zoom-in 0.3s cubic-bezier(0.34, 1.4, 0.64, 1) forwards; }
|
#np-overlay.np-open #np-modal { animation: lb-zoom-in 0.3s cubic-bezier(0.34, 1.4, 0.64, 1) forwards; }
|
||||||
#np-overlay.np-closing #np-modal { animation: lb-zoom-out 0.2s ease forwards; }
|
#np-overlay.np-closing #np-modal { animation: lb-zoom-out 0.2s ease forwards; }
|
||||||
|
|
@ -675,20 +683,62 @@ button.linklike.danger:hover { color: #fff; text-shadow: 0 0 6px var(--danger);
|
||||||
#np-left {
|
#np-left {
|
||||||
width: 38%; flex-shrink: 0;
|
width: 38%; flex-shrink: 0;
|
||||||
display: flex; flex-direction: column;
|
display: flex; flex-direction: column;
|
||||||
background: #000; overflow: hidden;
|
background: #000; overflow: visible;
|
||||||
border-right: 1px solid var(--border);
|
border-right: 1px solid var(--border);
|
||||||
|
position: relative; z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sleeve scene — handles tilt, hover scale, and record pull-out */
|
||||||
|
#np-sleeve-scene {
|
||||||
|
position: relative;
|
||||||
|
width: 115%;
|
||||||
|
margin: -1.5rem 0 0 -30%;
|
||||||
|
transform: rotate(-10deg);
|
||||||
|
transform-origin: 42% 50%;
|
||||||
|
transition: transform 0.35s cubic-bezier(0.34, 1.3, 0.64, 1);
|
||||||
|
}
|
||||||
|
#np-sleeve-scene:hover {
|
||||||
|
transform: rotate(-10deg) scale(1.05);
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 1:1 cover using padding-bottom trick */
|
/* 1:1 cover using padding-bottom trick */
|
||||||
#np-cover-wrap {
|
#np-cover-wrap {
|
||||||
width: 100%; position: relative; flex-shrink: 0;
|
width: 100%; position: relative; flex-shrink: 0;
|
||||||
|
overflow: visible;
|
||||||
}
|
}
|
||||||
#np-cover-wrap::before { content: ''; display: block; padding-bottom: 100%; }
|
#np-cover-wrap::before { content: ''; display: block; padding-bottom: 100%; }
|
||||||
|
|
||||||
|
/* Vinyl record — peeks out behind the sleeve */
|
||||||
|
#np-record {
|
||||||
|
position: absolute;
|
||||||
|
width: 100%; height: 100%;
|
||||||
|
top: 0; left: 18%;
|
||||||
|
z-index: 0;
|
||||||
|
object-fit: contain;
|
||||||
|
border-radius: 50%;
|
||||||
|
transition: left 0.35s cubic-bezier(0.34, 1.3, 0.64, 1);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
#np-sleeve-scene:hover #np-record {
|
||||||
|
left: 44%;
|
||||||
|
}
|
||||||
|
|
||||||
#np-cover {
|
#np-cover {
|
||||||
position: absolute; inset: 0;
|
position: absolute; inset: 0;
|
||||||
width: 100%; height: 100%; object-fit: cover;
|
width: 100%; height: 100%; object-fit: cover;
|
||||||
transition: opacity 0.3s ease;
|
transition: opacity 0.3s ease;
|
||||||
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
#np-cover-wrap::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 2;
|
||||||
|
border: 8px solid rgba(128, 128, 128, 0.8);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
#np-cover[data-lightbox] { cursor: pointer; }
|
||||||
|
|
||||||
/* Creator info below the square cover */
|
/* Creator info below the square cover */
|
||||||
#np-creator-info {
|
#np-creator-info {
|
||||||
|
|
@ -696,6 +746,11 @@ button.linklike.danger:hover { color: #fff; text-shadow: 0 0 6px var(--danger);
|
||||||
align-items: center; justify-content: center;
|
align-items: center; justify-content: center;
|
||||||
gap: 0.4rem; padding: 0.9rem 1rem;
|
gap: 0.4rem; padding: 0.9rem 1rem;
|
||||||
background: var(--bg-inset); overflow: hidden; text-align: center;
|
background: var(--bg-inset); overflow: hidden; text-align: center;
|
||||||
|
transition: background 0.15s ease, box-shadow 0.15s ease;
|
||||||
|
}
|
||||||
|
#np-creator-info[style*="cursor: pointer"]:hover {
|
||||||
|
background: var(--bg-panel);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--neon-cyan), inset 0 0 12px rgba(0, 255, 255, 0.1);
|
||||||
}
|
}
|
||||||
.np-creator-avatar {
|
.np-creator-avatar {
|
||||||
width: 58px; height: 58px; border-radius: 50%;
|
width: 58px; height: 58px; border-radius: 50%;
|
||||||
|
|
@ -713,7 +768,7 @@ button.linklike.danger:hover { color: #fff; text-shadow: 0 0 6px var(--danger);
|
||||||
.np-creator-name { font-size: 0.82rem; color: var(--fg); font-weight: 600; margin: 0; }
|
.np-creator-name { font-size: 0.82rem; color: var(--fg); font-weight: 600; margin: 0; }
|
||||||
|
|
||||||
/* ── Right panel ─────────────────────────────────────────────────── */
|
/* ── Right panel ─────────────────────────────────────────────────── */
|
||||||
#np-right { flex: 1; display: flex; flex-direction: column; min-width: 0; }
|
#np-right { flex: 1; display: flex; flex-direction: column; min-width: 0; position: relative; z-index: 0; }
|
||||||
|
|
||||||
#np-header {
|
#np-header {
|
||||||
padding: 0.9rem 1.25rem 0.8rem;
|
padding: 0.9rem 1.25rem 0.8rem;
|
||||||
|
|
@ -742,8 +797,9 @@ button.linklike.danger:hover { color: #fff; text-shadow: 0 0 6px var(--danger);
|
||||||
padding: 0.5rem 1rem;
|
padding: 0.5rem 1rem;
|
||||||
border-bottom: 1px solid rgba(255,255,255,0.04);
|
border-bottom: 1px solid rgba(255,255,255,0.04);
|
||||||
transition: background 0.1s;
|
transition: background 0.1s;
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.np-track:hover { background: rgba(255,255,255,0.04); }
|
.np-track:hover { background: rgba(255,255,255,0.08); }
|
||||||
.np-track.np-active { background: rgba(0,229,255,0.07); }
|
.np-track.np-active { background: rgba(0,229,255,0.07); }
|
||||||
|
|
||||||
/* Equalizer bars */
|
/* Equalizer bars */
|
||||||
|
|
@ -944,6 +1000,136 @@ button.linklike.danger:hover { color: #fff; text-shadow: 0 0 6px var(--danger);
|
||||||
}
|
}
|
||||||
@keyframes boot-blink { 50% { opacity: 0; } }
|
@keyframes boot-blink { 50% { opacity: 0; } }
|
||||||
|
|
||||||
|
/* ---------- Profiles ---------- */
|
||||||
|
.profiles-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||||
|
gap: 1.25rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
.profile-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 1.25rem 0.75rem;
|
||||||
|
background: var(--bg-panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--fg);
|
||||||
|
text-align: center;
|
||||||
|
transition: transform 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease;
|
||||||
|
}
|
||||||
|
.profile-card:hover {
|
||||||
|
transform: scale(1.04);
|
||||||
|
border-color: var(--neon-cyan);
|
||||||
|
box-shadow: 0 0 10px var(--neon-cyan-dim, rgba(0,255,255,0.25));
|
||||||
|
}
|
||||||
|
.profile-card-avatar-wrap {
|
||||||
|
width: 72px;
|
||||||
|
height: 72px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.profile-card-avatar {
|
||||||
|
width: 72px;
|
||||||
|
height: 72px;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
border: 2px solid var(--border);
|
||||||
|
}
|
||||||
|
.profile-card-placeholder {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--bg-inset);
|
||||||
|
font-size: 1.6rem;
|
||||||
|
font-weight: bold;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.profile-card-name {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.profile-card-meta {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Profile detail header */
|
||||||
|
.profile-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1.5rem;
|
||||||
|
padding: 1.5rem 0 1.5rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
.profile-header-avatar-wrap {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.profile-header-avatar {
|
||||||
|
width: 96px;
|
||||||
|
height: 96px;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
border: 2px solid var(--border);
|
||||||
|
background: var(--bg-panel);
|
||||||
|
}
|
||||||
|
.profile-header-placeholder {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 2.5rem;
|
||||||
|
font-weight: bold;
|
||||||
|
color: var(--muted);
|
||||||
|
background: var(--bg-inset);
|
||||||
|
}
|
||||||
|
.profile-header-info h1 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Spacing and dividers between sections on profile page */
|
||||||
|
.profile-header ~ section {
|
||||||
|
margin-top: 3.5rem;
|
||||||
|
padding-top: 3rem;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.profile-header ~ section::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 1px;
|
||||||
|
background: linear-gradient(to right, transparent, var(--neon-cyan), transparent);
|
||||||
|
box-shadow: 0 0 8px var(--neon-cyan), 0 0 20px rgba(0, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
/* No line/extra space before the first section */
|
||||||
|
.profile-header + section {
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding-top: 0;
|
||||||
|
}
|
||||||
|
.profile-header + section::before {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 4-wide grid for songs on profile page */
|
||||||
|
.grid.grid-4 {
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Section header with inline action button */
|
||||||
|
.section-header-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
.section-header-row h2 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------- Mobile ---------- */
|
/* ---------- Mobile ---------- */
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
.player-bar { flex-wrap: wrap; }
|
.player-bar { flex-wrap: wrap; }
|
||||||
|
|
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 187 KiB |
|
|
@ -18,7 +18,12 @@
|
||||||
|
|
||||||
/* Left panel */
|
/* Left panel */
|
||||||
'<div id="np-left">' +
|
'<div id="np-left">' +
|
||||||
'<div id="np-cover-wrap"><img id="np-cover" src="" alt=""></div>' +
|
'<div id="np-sleeve-scene">' +
|
||||||
|
'<div id="np-cover-wrap">' +
|
||||||
|
'<img id="np-record" src="/static/images/record.png" alt="">' +
|
||||||
|
'<img id="np-cover" src="" alt="">' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>' +
|
||||||
'<div id="np-creator-info">' +
|
'<div id="np-creator-info">' +
|
||||||
'<div id="np-creator-avatar-wrap"></div>' +
|
'<div id="np-creator-avatar-wrap"></div>' +
|
||||||
'<p class="np-creator-label">Created by</p>' +
|
'<p class="np-creator-label">Created by</p>' +
|
||||||
|
|
@ -100,11 +105,15 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
function setCover(src) {
|
function setCover(src) {
|
||||||
if (!src) { coverImg.style.opacity = '0'; return; }
|
if (!src) {
|
||||||
|
coverImg.style.opacity = '0';
|
||||||
|
coverImg.removeAttribute('data-lightbox');
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (coverImg.src === src) return;
|
if (coverImg.src === src) return;
|
||||||
coverImg.style.opacity = '0';
|
coverImg.style.opacity = '0';
|
||||||
coverImg.onload = () => { coverImg.style.opacity = '1'; };
|
coverImg.onload = () => { coverImg.style.opacity = '1'; coverImg.setAttribute('data-lightbox', ''); };
|
||||||
coverImg.onerror = () => { coverImg.style.opacity = '0'; };
|
coverImg.onerror = () => { coverImg.style.opacity = '0'; coverImg.removeAttribute('data-lightbox'); };
|
||||||
coverImg.src = src;
|
coverImg.src = src;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -137,11 +146,13 @@
|
||||||
|
|
||||||
function populateCreator(creatorData) {
|
function populateCreator(creatorData) {
|
||||||
creatorWrap.innerHTML = '';
|
creatorWrap.innerHTML = '';
|
||||||
|
const creatorInfo = overlay.querySelector('#np-creator-info');
|
||||||
if (!creatorData) {
|
if (!creatorData) {
|
||||||
overlay.querySelector('#np-creator-info').style.display = 'none';
|
creatorInfo.style.display = 'none';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
overlay.querySelector('#np-creator-info').style.display = '';
|
creatorInfo.style.display = '';
|
||||||
|
|
||||||
if (creatorData.avatarPath) {
|
if (creatorData.avatarPath) {
|
||||||
const img = document.createElement('img');
|
const img = document.createElement('img');
|
||||||
img.src = '/media/' + creatorData.avatarPath;
|
img.src = '/media/' + creatorData.avatarPath;
|
||||||
|
|
@ -155,6 +166,15 @@
|
||||||
creatorWrap.appendChild(ph);
|
creatorWrap.appendChild(ph);
|
||||||
}
|
}
|
||||||
creatorName.textContent = creatorData.displayName || '';
|
creatorName.textContent = creatorData.displayName || '';
|
||||||
|
|
||||||
|
// Make the whole creator block a link if we have a profile slug.
|
||||||
|
if (creatorData.slug) {
|
||||||
|
creatorInfo.style.cursor = 'pointer';
|
||||||
|
creatorInfo.onclick = () => { window.open('/profiles/' + creatorData.slug, '_blank'); };
|
||||||
|
} else {
|
||||||
|
creatorInfo.style.cursor = '';
|
||||||
|
creatorInfo.onclick = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function syncPlayPause() {
|
function syncPlayPause() {
|
||||||
|
|
@ -252,6 +272,13 @@
|
||||||
reopen();
|
reopen();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Click on a track row in the overlay — jump to that song.
|
||||||
|
const trackRow = e.target.closest('.np-track');
|
||||||
|
if (trackRow && isOpen) {
|
||||||
|
const id = trackRow.dataset.trackId;
|
||||||
|
if (id) window.dispatchEvent(new CustomEvent('briTunes:jumpto', { detail: { id } }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
// Play All / Shuffle All on a playlist page
|
// Play All / Shuffle All on a playlist page
|
||||||
const btn = e.target.closest('[data-nowplaying-meta]');
|
const btn = e.target.closest('[data-nowplaying-meta]');
|
||||||
if (btn) {
|
if (btn) {
|
||||||
|
|
|
||||||
|
|
@ -99,6 +99,15 @@
|
||||||
repeatBtn.classList.toggle('active', repeatOn);
|
repeatBtn.classList.toggle('active', repeatOn);
|
||||||
repeatBtn.setAttribute('aria-pressed', repeatOn);
|
repeatBtn.setAttribute('aria-pressed', repeatOn);
|
||||||
});
|
});
|
||||||
|
window.addEventListener('briTunes:jumpto', (e) => {
|
||||||
|
const id = e.detail && e.detail.id;
|
||||||
|
if (!id) return;
|
||||||
|
const idx = state.queue.findIndex(s => String(s.id) === String(id));
|
||||||
|
if (idx === -1) return;
|
||||||
|
state.index = idx;
|
||||||
|
playCurrent();
|
||||||
|
});
|
||||||
|
|
||||||
window.addEventListener('briTunes:navigate', () => {
|
window.addEventListener('briTunes:navigate', () => {
|
||||||
if (window.briTunesNowPlaying) highlightNowPlaying(window.briTunesNowPlaying.id);
|
if (window.briTunesNowPlaying) highlightNowPlaying(window.briTunesNowPlaying.id);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -104,6 +104,43 @@ router.get('/playlists/:slug', (req, res, next) => {
|
||||||
res.render('public/playlist', { title: playlist.title, playlist, tracks, creator });
|
res.render('public/playlist', { title: playlist.title, playlist, tracks, creator });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.get('/profiles', (req, res) => {
|
||||||
|
const profiles = users.listVerified();
|
||||||
|
res.render('public/profiles', { title: 'Profiles', profiles });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/profiles/:slug', (req, res, next) => {
|
||||||
|
const profile = users.findBySlug(req.params.slug);
|
||||||
|
if (!profile) return next();
|
||||||
|
const loggedIn = !!req.session.user;
|
||||||
|
const userId = req.session.user?.id || null;
|
||||||
|
|
||||||
|
const userPlaylists = social.enrichPlaylists(playlists.listPublicByUser(profile.id, loggedIn), userId);
|
||||||
|
const userSongs = social.enrichSongs(songs.listPublicByUser(profile.id, loggedIn), userId);
|
||||||
|
|
||||||
|
const likedSongRows = social.getLikedSongs(profile.id, loggedIn);
|
||||||
|
// Map raw DB rows to song view objects
|
||||||
|
const likedSongs = social.enrichSongs(
|
||||||
|
likedSongRows.map(row => songs.findById(row.id)).filter(Boolean),
|
||||||
|
userId
|
||||||
|
);
|
||||||
|
|
||||||
|
const likedPlaylistRows = social.getLikedPlaylists(profile.id, loggedIn);
|
||||||
|
const likedPlaylists = social.enrichPlaylists(
|
||||||
|
likedPlaylistRows.map(row => playlists.findById(row.id)).filter(Boolean),
|
||||||
|
userId
|
||||||
|
);
|
||||||
|
|
||||||
|
res.render('public/profile', {
|
||||||
|
title: profile.displayName + "'s Profile",
|
||||||
|
profile,
|
||||||
|
userPlaylists,
|
||||||
|
userSongs,
|
||||||
|
likedSongs,
|
||||||
|
likedPlaylists,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// Audio streaming with HTTP Range support.
|
// Audio streaming with HTTP Range support.
|
||||||
router.get('/stream/:id', (req, res, next) => {
|
router.get('/stream/:id', (req, res, next) => {
|
||||||
const song = songs.findById(Number(req.params.id));
|
const song = songs.findById(Number(req.params.id));
|
||||||
|
|
|
||||||
|
|
@ -239,8 +239,33 @@ function setOrder(playlistId, songIds) {
|
||||||
tx();
|
tx();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const listPublicByUserGuestStmt = db.prepare(`
|
||||||
|
SELECT p.* FROM playlists p
|
||||||
|
WHERE p.created_by = ? AND p.visibility = 'public'
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM playlist_songs ps
|
||||||
|
JOIN songs s ON s.id = ps.song_id
|
||||||
|
WHERE ps.playlist_id = p.id AND s.visibility IN ('logged_in', 'private')
|
||||||
|
)
|
||||||
|
ORDER BY p.created_at DESC
|
||||||
|
`);
|
||||||
|
const listPublicByUserLoggedStmt = db.prepare(`
|
||||||
|
SELECT p.* FROM playlists p
|
||||||
|
WHERE p.created_by = ? AND p.visibility IN ('public', 'logged_in')
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM playlist_songs ps
|
||||||
|
JOIN songs s ON s.id = ps.song_id
|
||||||
|
WHERE ps.playlist_id = p.id AND s.visibility = 'private'
|
||||||
|
)
|
||||||
|
ORDER BY p.created_at DESC
|
||||||
|
`);
|
||||||
|
|
||||||
|
function listPublicByUser(userId, loggedIn = false) {
|
||||||
|
return (loggedIn ? listPublicByUserLoggedStmt : listPublicByUserGuestStmt).all(userId).map(toView);
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
create, update, setCover, setVisibility, remove, findById, findBySlug, getEffectiveVisibility,
|
create, update, setCover, setVisibility, remove, findById, findBySlug, getEffectiveVisibility,
|
||||||
listVisible, listAll, listByUser, featured,
|
listVisible, listAll, listByUser, listPublicByUser, featured,
|
||||||
getTracks, trackCount, addTrack, removeTrack, setOrder,
|
getTracks, trackCount, addTrack, removeTrack, setOrder,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -71,8 +71,53 @@ function enrichPlaylists(playlists, userId = null) {
|
||||||
return playlists;
|
return playlists;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Profile liked/favorited queries ─────────────────────────────────────────
|
||||||
|
const likedSongsGuestStmt = db.prepare(`
|
||||||
|
SELECT s.* FROM song_likes sl
|
||||||
|
JOIN songs s ON s.id = sl.song_id
|
||||||
|
WHERE sl.user_id = ? AND s.visibility = 'public'
|
||||||
|
ORDER BY sl.created_at DESC
|
||||||
|
`);
|
||||||
|
const likedSongsUserStmt = db.prepare(`
|
||||||
|
SELECT s.* FROM song_likes sl
|
||||||
|
JOIN songs s ON s.id = sl.song_id
|
||||||
|
WHERE sl.user_id = ? AND s.visibility IN ('public', 'logged_in')
|
||||||
|
ORDER BY sl.created_at DESC
|
||||||
|
`);
|
||||||
|
const likedPlaylistsGuestStmt = db.prepare(`
|
||||||
|
SELECT p.* FROM playlist_likes pl
|
||||||
|
JOIN playlists p ON p.id = pl.playlist_id
|
||||||
|
WHERE pl.user_id = ? AND p.visibility = 'public'
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM playlist_songs ps
|
||||||
|
JOIN songs s ON s.id = ps.song_id
|
||||||
|
WHERE ps.playlist_id = p.id AND s.visibility IN ('logged_in', 'private')
|
||||||
|
)
|
||||||
|
ORDER BY pl.created_at DESC
|
||||||
|
`);
|
||||||
|
const likedPlaylistsUserStmt = db.prepare(`
|
||||||
|
SELECT p.* FROM playlist_likes pl
|
||||||
|
JOIN playlists p ON p.id = pl.playlist_id
|
||||||
|
WHERE pl.user_id = ? AND p.visibility IN ('public', 'logged_in')
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM playlist_songs ps
|
||||||
|
JOIN songs s ON s.id = ps.song_id
|
||||||
|
WHERE ps.playlist_id = p.id AND s.visibility = 'private'
|
||||||
|
)
|
||||||
|
ORDER BY pl.created_at DESC
|
||||||
|
`);
|
||||||
|
|
||||||
|
function getLikedSongs(userId, loggedIn = false) {
|
||||||
|
return (loggedIn ? likedSongsUserStmt : likedSongsGuestStmt).all(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLikedPlaylists(userId, loggedIn = false) {
|
||||||
|
return (loggedIn ? likedPlaylistsUserStmt : likedPlaylistsGuestStmt).all(userId);
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
toggleSongLike, toggleSongFavorite,
|
toggleSongLike, toggleSongFavorite,
|
||||||
togglePlaylistLike, togglePlaylistFavorite,
|
togglePlaylistLike, togglePlaylistFavorite,
|
||||||
enrichSongs, enrichPlaylists,
|
enrichSongs, enrichPlaylists,
|
||||||
|
getLikedSongs, getLikedPlaylists,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -175,4 +175,11 @@ function recent(n = 8, loggedIn = false) {
|
||||||
return (loggedIn ? recentUserStmt : recentGuestStmt).all(n).map(toView);
|
return (loggedIn ? recentUserStmt : recentGuestStmt).all(n).map(toView);
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { create, update, setCover, remove, findById, findBySlug, list, listAll, listByUser, listForManagement, recent };
|
const listPublicByUserGuestStmt = db.prepare(`SELECT * FROM songs WHERE uploaded_by = ? AND visibility = 'public' ORDER BY created_at DESC`);
|
||||||
|
const listPublicByUserLoggedStmt = db.prepare(`SELECT * FROM songs WHERE uploaded_by = ? AND visibility IN ('public', 'logged_in') ORDER BY created_at DESC`);
|
||||||
|
|
||||||
|
function listPublicByUser(userId, loggedIn = false) {
|
||||||
|
return (loggedIn ? listPublicByUserLoggedStmt : listPublicByUserGuestStmt).all(userId).map(toView);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { create, update, setCover, remove, findById, findBySlug, list, listAll, listByUser, listPublicByUser, listForManagement, recent };
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,45 @@ const playlistsByUserStmt = db.prepare('SELECT * FROM playlists WHERE crea
|
||||||
const deleteSongsByUserStmt = db.prepare('DELETE FROM songs WHERE uploaded_by = ?');
|
const deleteSongsByUserStmt = db.prepare('DELETE FROM songs WHERE uploaded_by = ?');
|
||||||
const deletePlaylistsByUserStmt = db.prepare('DELETE FROM playlists WHERE created_by = ?');
|
const deletePlaylistsByUserStmt = db.prepare('DELETE FROM playlists WHERE created_by = ?');
|
||||||
const updateAvatarStmt = db.prepare('UPDATE users SET avatar_path = ? WHERE id = ?');
|
const updateAvatarStmt = db.prepare('UPDATE users SET avatar_path = ? WHERE id = ?');
|
||||||
|
const findBySlugIdStmt = db.prepare('SELECT id FROM users WHERE slug = ?');
|
||||||
|
const findBySlugExactStmt = db.prepare('SELECT * FROM users WHERE slug = ?');
|
||||||
|
const updateSlugStmt = db.prepare('UPDATE users SET slug = ? WHERE id = ?');
|
||||||
|
const listVerifiedStmt = db.prepare(`
|
||||||
|
SELECT u.*,
|
||||||
|
(SELECT COUNT(*) FROM songs WHERE uploaded_by = u.id) AS song_count,
|
||||||
|
(SELECT COUNT(*) FROM playlists WHERE created_by = u.id) AS playlist_count
|
||||||
|
FROM users u
|
||||||
|
WHERE u.email_verified = 1 AND u.disabled = 0
|
||||||
|
ORDER BY u.display_name ASC
|
||||||
|
`);
|
||||||
|
|
||||||
|
function toSlug(text) {
|
||||||
|
return (text || '')
|
||||||
|
.toLowerCase()
|
||||||
|
.trim()
|
||||||
|
.replace(/[^a-z0-9\s-]/g, '')
|
||||||
|
.replace(/\s+/g, '-')
|
||||||
|
.replace(/-+/g, '-')
|
||||||
|
.replace(/^-|-$/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateUniqueUserSlug(displayName, excludeId = null) {
|
||||||
|
const base = toSlug(displayName) || 'user';
|
||||||
|
let candidate = base;
|
||||||
|
let n = 2;
|
||||||
|
// eslint-disable-next-line no-constant-condition
|
||||||
|
while (true) {
|
||||||
|
const row = findBySlugIdStmt.get(candidate);
|
||||||
|
if (!row || row.id === excludeId) return candidate;
|
||||||
|
candidate = `${base}-${n++}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backfill slugs for any users missing one (runs once after migration 009).
|
||||||
|
const _noSlugRows = db.prepare('SELECT id, display_name FROM users WHERE slug IS NULL').all();
|
||||||
|
for (const row of _noSlugRows) {
|
||||||
|
updateSlugStmt.run(generateUniqueUserSlug(row.display_name, row.id), row.id);
|
||||||
|
}
|
||||||
|
|
||||||
function publicView(row) {
|
function publicView(row) {
|
||||||
if (!row) return null;
|
if (!row) return null;
|
||||||
|
|
@ -38,6 +77,9 @@ function publicView(row) {
|
||||||
emailVerified: row.email_verified === 1,
|
emailVerified: row.email_verified === 1,
|
||||||
disabled: row.disabled === 1,
|
disabled: row.disabled === 1,
|
||||||
avatarPath: row.avatar_path || null,
|
avatarPath: row.avatar_path || null,
|
||||||
|
slug: row.slug || null,
|
||||||
|
songCount: row.song_count != null ? row.song_count : undefined,
|
||||||
|
playlistCount: row.playlist_count != null ? row.playlist_count : undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -51,6 +93,8 @@ async function createUser({ email, displayName, password }) {
|
||||||
role = 'admin';
|
role = 'admin';
|
||||||
}
|
}
|
||||||
const info = insertStmt.run(email, displayName.trim(), hash, role);
|
const info = insertStmt.run(email, displayName.trim(), hash, role);
|
||||||
|
const slug = generateUniqueUserSlug(displayName.trim(), info.lastInsertRowid);
|
||||||
|
updateSlugStmt.run(slug, info.lastInsertRowid);
|
||||||
return publicView(findByIdStmt.get(info.lastInsertRowid));
|
return publicView(findByIdStmt.get(info.lastInsertRowid));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -75,6 +119,8 @@ function findById(id) {
|
||||||
|
|
||||||
function updateDisplayName(id, displayName) {
|
function updateDisplayName(id, displayName) {
|
||||||
updateNameStmt.run(displayName.trim(), id);
|
updateNameStmt.run(displayName.trim(), id);
|
||||||
|
const slug = generateUniqueUserSlug(displayName.trim(), id);
|
||||||
|
updateSlugStmt.run(slug, id);
|
||||||
return findById(id);
|
return findById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -136,16 +182,26 @@ function deleteUserWithContent(id) {
|
||||||
return { songs: userSongs, playlists: userPlaylists };
|
return { songs: userSongs, playlists: userPlaylists };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function listVerified() {
|
||||||
|
return listVerifiedStmt.all().map(publicView);
|
||||||
|
}
|
||||||
|
|
||||||
|
function findBySlug(slug) {
|
||||||
|
return publicView(findBySlugExactStmt.get(slug));
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
createUser,
|
createUser,
|
||||||
verifyCredentials,
|
verifyCredentials,
|
||||||
findByEmail,
|
findByEmail,
|
||||||
findById,
|
findById,
|
||||||
|
findBySlug,
|
||||||
updateDisplayName,
|
updateDisplayName,
|
||||||
changePassword,
|
changePassword,
|
||||||
createVerificationToken,
|
createVerificationToken,
|
||||||
verifyEmailToken,
|
verifyEmailToken,
|
||||||
listAllUsers,
|
listAllUsers,
|
||||||
|
listVerified,
|
||||||
setDisabled,
|
setDisabled,
|
||||||
markVerifiedAdmin,
|
markVerifiedAdmin,
|
||||||
deleteUserWithContent,
|
deleteUserWithContent,
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
<nav class="nav">
|
<nav class="nav">
|
||||||
<a href="/songs" class="<%= currentPath && currentPath.startsWith('/songs') ? 'active' : '' %>">Songs</a>
|
<a href="/songs" class="<%= currentPath && currentPath.startsWith('/songs') ? 'active' : '' %>">Songs</a>
|
||||||
<a href="/playlists" class="<%= currentPath && currentPath.startsWith('/playlists') ? 'active' : '' %>">Playlists</a>
|
<a href="/playlists" class="<%= currentPath && currentPath.startsWith('/playlists') ? 'active' : '' %>">Playlists</a>
|
||||||
|
<a href="/profiles" class="<%= currentPath && currentPath.startsWith('/profiles') ? 'active' : '' %>">Profiles</a>
|
||||||
<% if (user) { %>
|
<% if (user) { %>
|
||||||
<a href="/admin/songs" class="<%= currentPath && currentPath.startsWith('/admin') && !currentPath.startsWith('/admin/users') ? 'active' : '' %>">My Music</a>
|
<a href="/admin/songs" class="<%= currentPath && currentPath.startsWith('/admin') && !currentPath.startsWith('/admin/users') ? 'active' : '' %>">My Music</a>
|
||||||
<% if (user.role === 'admin') { %>
|
<% if (user.role === 'admin') { %>
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@
|
||||||
aria-pressed="<%= playlist.userFavorited ? 'true' : 'false' %>"
|
aria-pressed="<%= playlist.userFavorited ? 'true' : 'false' %>"
|
||||||
title="Favorite">⭐ <span class="social-count"><%= playlist.favoriteCount || 0 %></span></button>
|
title="Favorite">⭐ <span class="social-count"><%= playlist.favoriteCount || 0 %></span></button>
|
||||||
</div>
|
</div>
|
||||||
<% const npMeta = JSON.stringify({ title: playlist.title, description: playlist.description || null, cover: playlist.coverPath ? '/media/' + playlist.coverPath : null, trackCount: tracks.length, creator: creator ? { displayName: creator.displayName, avatarPath: creator.avatarPath || null } : null }); %>
|
<% const npMeta = JSON.stringify({ title: playlist.title, description: playlist.description || null, cover: playlist.coverPath ? '/media/' + playlist.coverPath : null, trackCount: tracks.length, creator: creator ? { displayName: creator.displayName, avatarPath: creator.avatarPath || null, slug: creator.slug || null } : null }); %>
|
||||||
<div class="playlist-btn-row">
|
<div class="playlist-btn-row">
|
||||||
<div class="playlist-btn-left">
|
<div class="playlist-btn-left">
|
||||||
<button type="button" class="btn-primary"
|
<button type="button" class="btn-primary"
|
||||||
|
|
@ -45,7 +45,11 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<% if (creator) { %>
|
<% if (creator) { %>
|
||||||
|
<% if (creator.slug) { %>
|
||||||
|
<a href="/profiles/<%= creator.slug %>" class="detail-creator-card">
|
||||||
|
<% } else { %>
|
||||||
<div class="detail-creator-card">
|
<div class="detail-creator-card">
|
||||||
|
<% } %>
|
||||||
<% if (creator.avatarPath) { %>
|
<% if (creator.avatarPath) { %>
|
||||||
<img src="/media/<%= creator.avatarPath %>" alt="" class="detail-creator-avatar">
|
<img src="/media/<%= creator.avatarPath %>" alt="" class="detail-creator-avatar">
|
||||||
<% } else { %>
|
<% } else { %>
|
||||||
|
|
@ -55,8 +59,12 @@
|
||||||
<% } %>
|
<% } %>
|
||||||
<p class="detail-creator-label">Created by</p>
|
<p class="detail-creator-label">Created by</p>
|
||||||
<p class="detail-creator-name"><%= creator.displayName %></p>
|
<p class="detail-creator-name"><%= creator.displayName %></p>
|
||||||
|
<% if (creator.slug) { %>
|
||||||
|
</a>
|
||||||
|
<% } else { %>
|
||||||
</div>
|
</div>
|
||||||
<% } %>
|
<% } %>
|
||||||
|
<% } %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,161 @@
|
||||||
|
<div class="profile-header">
|
||||||
|
<div class="profile-header-avatar-wrap">
|
||||||
|
<% if (profile.avatarPath) { %>
|
||||||
|
<img src="/media/<%= profile.avatarPath %>" alt="" class="profile-header-avatar">
|
||||||
|
<% } else { %>
|
||||||
|
<div class="profile-header-avatar profile-header-placeholder">
|
||||||
|
<%= (profile.displayName || '?').charAt(0).toUpperCase() %>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
<div class="profile-header-info">
|
||||||
|
<h1><%= profile.displayName %></h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2><%= profile.displayName %>'s Playlists</h2>
|
||||||
|
<% if (userPlaylists.length === 0) { %>
|
||||||
|
<p class="muted">No playlists yet.</p>
|
||||||
|
<% } else { %>
|
||||||
|
<div class="grid">
|
||||||
|
<% userPlaylists.forEach((p) => { %>
|
||||||
|
<a class="card" href="/playlists/<%= p.slug %>">
|
||||||
|
<% 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><% } %>
|
||||||
|
</div>
|
||||||
|
<% if (p.description) { %><div class="muted small"><%= p.description %></div><% } %>
|
||||||
|
<div class="card-footer">
|
||||||
|
<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>
|
||||||
|
<% }) %>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<div class="section-header-row">
|
||||||
|
<h2><%= profile.displayName %>'s Songs</h2>
|
||||||
|
<% if (userSongs.length > 0) { %>
|
||||||
|
<button type="button" class="btn-primary btn-small"
|
||||||
|
data-shuffle-playlist="<%= JSON.stringify(userSongs.map((s) => ({ 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 }))) %>">
|
||||||
|
⇄ Shuffle All
|
||||||
|
</button>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
<% if (userSongs.length === 0) { %>
|
||||||
|
<p class="muted">No songs yet.</p>
|
||||||
|
<% } else { %>
|
||||||
|
<div class="grid grid-4">
|
||||||
|
<% userSongs.forEach((s) => { %>
|
||||||
|
<a class="card" href="/songs/<%= s.slug %>">
|
||||||
|
<% if (s.coverPath) { %>
|
||||||
|
<img src="/media/<%= s.coverPath %>" alt="" />
|
||||||
|
<% } else { %>
|
||||||
|
<div class="cover-fallback">♪</div>
|
||||||
|
<% } %>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="card-title"><%= s.title %></div>
|
||||||
|
<div class="muted small"><%= s.artist %></div>
|
||||||
|
<div class="card-footer">
|
||||||
|
<div class="card-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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
<% }) %>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<div class="section-header-row">
|
||||||
|
<h2><%= profile.displayName %>'s Liked Songs</h2>
|
||||||
|
<% if (likedSongs.length > 0) { %>
|
||||||
|
<button type="button" class="btn-primary btn-small"
|
||||||
|
data-shuffle-playlist="<%= JSON.stringify(likedSongs.map((s) => ({ 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 }))) %>">
|
||||||
|
⇄ Shuffle All
|
||||||
|
</button>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
<% if (likedSongs.length === 0) { %>
|
||||||
|
<p class="muted">No liked songs.</p>
|
||||||
|
<% } else { %>
|
||||||
|
<%- include('../partials/song_list', { songs: likedSongs }) %>
|
||||||
|
<% } %>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2><%= profile.displayName %>'s Liked Playlists</h2>
|
||||||
|
<% if (likedPlaylists.length === 0) { %>
|
||||||
|
<p class="muted">No liked playlists.</p>
|
||||||
|
<% } else { %>
|
||||||
|
<div class="grid">
|
||||||
|
<% likedPlaylists.forEach((p) => { %>
|
||||||
|
<a class="card" href="/playlists/<%= p.slug %>">
|
||||||
|
<% 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><% } %>
|
||||||
|
</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' %>"
|
||||||
|
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>
|
||||||
|
<% }) %>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
</section>
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
<h1>Profiles</h1>
|
||||||
|
<% if (profiles.length === 0) { %>
|
||||||
|
<p class="muted">No profiles yet.</p>
|
||||||
|
<% } else { %>
|
||||||
|
<div class="profiles-grid">
|
||||||
|
<% profiles.forEach((p) => { %>
|
||||||
|
<a class="profile-card" href="/profiles/<%= p.slug %>">
|
||||||
|
<div class="profile-card-avatar-wrap">
|
||||||
|
<% if (p.avatarPath) { %>
|
||||||
|
<img src="/media/<%= p.avatarPath %>" alt="" class="profile-card-avatar">
|
||||||
|
<% } else { %>
|
||||||
|
<div class="profile-card-avatar profile-card-placeholder">
|
||||||
|
<%= (p.displayName || '?').charAt(0).toUpperCase() %>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
<div class="profile-card-name"><%= p.displayName %></div>
|
||||||
|
<div class="profile-card-meta muted small">
|
||||||
|
<% if (p.songCount) { %><%= p.songCount %> song<%= p.songCount === 1 ? '' : 's' %><% } %>
|
||||||
|
<% if (p.songCount && p.playlistCount) { %> · <% } %>
|
||||||
|
<% if (p.playlistCount) { %><%= p.playlistCount %> playlist<%= p.playlistCount === 1 ? '' : 's' %><% } %>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
<% }) %>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
|
@ -37,7 +37,11 @@
|
||||||
<a href="/songs" class="btn-link">← Back to songs</a>
|
<a href="/songs" class="btn-link">← Back to songs</a>
|
||||||
</div>
|
</div>
|
||||||
<% if (creator) { %>
|
<% if (creator) { %>
|
||||||
|
<% if (creator.slug) { %>
|
||||||
|
<a href="/profiles/<%= creator.slug %>" class="detail-creator-card">
|
||||||
|
<% } else { %>
|
||||||
<div class="detail-creator-card">
|
<div class="detail-creator-card">
|
||||||
|
<% } %>
|
||||||
<% if (creator.avatarPath) { %>
|
<% if (creator.avatarPath) { %>
|
||||||
<img src="/media/<%= creator.avatarPath %>" alt="" class="detail-creator-avatar">
|
<img src="/media/<%= creator.avatarPath %>" alt="" class="detail-creator-avatar">
|
||||||
<% } else { %>
|
<% } else { %>
|
||||||
|
|
@ -47,7 +51,11 @@
|
||||||
<% } %>
|
<% } %>
|
||||||
<p class="detail-creator-label">Added by</p>
|
<p class="detail-creator-label">Added by</p>
|
||||||
<p class="detail-creator-name"><%= creator.displayName %></p>
|
<p class="detail-creator-name"><%= creator.displayName %></p>
|
||||||
|
<% if (creator.slug) { %>
|
||||||
|
</a>
|
||||||
|
<% } else { %>
|
||||||
</div>
|
</div>
|
||||||
<% } %>
|
<% } %>
|
||||||
|
<% } %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue