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:
Brian Fertig 2026-04-11 13:46:12 -06:00
parent a4c1595eaa
commit 25d68e1dcb
15 changed files with 616 additions and 18 deletions

View File

@ -0,0 +1,2 @@
ALTER TABLE users ADD COLUMN slug TEXT;
CREATE UNIQUE INDEX users_slug_unique ON users (slug);

View File

@ -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;
gap: 0.35rem; flex-shrink: 0; text-align: center;
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 {
width: 72px; height: 72px;
@ -581,7 +589,7 @@ button.linklike.danger:hover { color: #fff; text-shadow: 0 0 6px var(--danger);
display: none;
position: fixed; inset: 0;
background: rgba(0,0,0,0);
z-index: 2000;
z-index: 3000;
align-items: center; justify-content: center;
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);
width: min(940px, 92vw);
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-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 {
width: 38%; flex-shrink: 0;
display: flex; flex-direction: column;
background: #000; overflow: hidden;
background: #000; overflow: visible;
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 */
#np-cover-wrap {
width: 100%; position: relative; flex-shrink: 0;
overflow: visible;
}
#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 {
position: absolute; inset: 0;
width: 100%; height: 100%; object-fit: cover;
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 */
#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;
gap: 0.4rem; padding: 0.9rem 1rem;
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 {
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; }
/* ── 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 {
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;
border-bottom: 1px solid rgba(255,255,255,0.04);
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); }
/* 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; } }
/* ---------- 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 ---------- */
@media (max-width: 720px) {
.player-bar { flex-wrap: wrap; }

BIN
public/images/record.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

View File

@ -18,7 +18,12 @@
/* Left panel */
'<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-avatar-wrap"></div>' +
'<p class="np-creator-label">Created by</p>' +
@ -100,11 +105,15 @@
}
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;
coverImg.style.opacity = '0';
coverImg.onload = () => { coverImg.style.opacity = '1'; };
coverImg.onerror = () => { coverImg.style.opacity = '0'; };
coverImg.onload = () => { coverImg.style.opacity = '1'; coverImg.setAttribute('data-lightbox', ''); };
coverImg.onerror = () => { coverImg.style.opacity = '0'; coverImg.removeAttribute('data-lightbox'); };
coverImg.src = src;
}
@ -137,11 +146,13 @@
function populateCreator(creatorData) {
creatorWrap.innerHTML = '';
const creatorInfo = overlay.querySelector('#np-creator-info');
if (!creatorData) {
overlay.querySelector('#np-creator-info').style.display = 'none';
creatorInfo.style.display = 'none';
return;
}
overlay.querySelector('#np-creator-info').style.display = '';
creatorInfo.style.display = '';
if (creatorData.avatarPath) {
const img = document.createElement('img');
img.src = '/media/' + creatorData.avatarPath;
@ -155,6 +166,15 @@
creatorWrap.appendChild(ph);
}
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() {
@ -252,6 +272,13 @@
reopen();
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
const btn = e.target.closest('[data-nowplaying-meta]');
if (btn) {

View File

@ -99,6 +99,15 @@
repeatBtn.classList.toggle('active', 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', () => {
if (window.briTunesNowPlaying) highlightNowPlaying(window.briTunesNowPlaying.id);
});

View File

@ -104,6 +104,43 @@ router.get('/playlists/:slug', (req, res, next) => {
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.
router.get('/stream/:id', (req, res, next) => {
const song = songs.findById(Number(req.params.id));

View File

@ -239,8 +239,33 @@ function setOrder(playlistId, songIds) {
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 = {
create, update, setCover, setVisibility, remove, findById, findBySlug, getEffectiveVisibility,
listVisible, listAll, listByUser, featured,
listVisible, listAll, listByUser, listPublicByUser, featured,
getTracks, trackCount, addTrack, removeTrack, setOrder,
};

View File

@ -71,8 +71,53 @@ function enrichPlaylists(playlists, userId = null) {
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 = {
toggleSongLike, toggleSongFavorite,
togglePlaylistLike, togglePlaylistFavorite,
enrichSongs, enrichPlaylists,
getLikedSongs, getLikedPlaylists,
};

View File

@ -175,4 +175,11 @@ function recent(n = 8, loggedIn = false) {
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 };

View File

@ -29,6 +29,45 @@ const playlistsByUserStmt = db.prepare('SELECT * FROM playlists WHERE crea
const deleteSongsByUserStmt = db.prepare('DELETE FROM songs WHERE uploaded_by = ?');
const deletePlaylistsByUserStmt = db.prepare('DELETE FROM playlists WHERE created_by = ?');
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) {
if (!row) return null;
@ -38,6 +77,9 @@ function publicView(row) {
emailVerified: row.email_verified === 1,
disabled: row.disabled === 1,
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';
}
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));
}
@ -75,6 +119,8 @@ function findById(id) {
function updateDisplayName(id, displayName) {
updateNameStmt.run(displayName.trim(), id);
const slug = generateUniqueUserSlug(displayName.trim(), id);
updateSlugStmt.run(slug, id);
return findById(id);
}
@ -136,16 +182,26 @@ function deleteUserWithContent(id) {
return { songs: userSongs, playlists: userPlaylists };
}
function listVerified() {
return listVerifiedStmt.all().map(publicView);
}
function findBySlug(slug) {
return publicView(findBySlugExactStmt.get(slug));
}
module.exports = {
createUser,
verifyCredentials,
findByEmail,
findById,
findBySlug,
updateDisplayName,
changePassword,
createVerificationToken,
verifyEmailToken,
listAllUsers,
listVerified,
setDisabled,
markVerifiedAdmin,
deleteUserWithContent,

View File

@ -4,6 +4,7 @@
<nav class="nav">
<a href="/songs" class="<%= currentPath && currentPath.startsWith('/songs') ? 'active' : '' %>">Songs</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) { %>
<a href="/admin/songs" class="<%= currentPath && currentPath.startsWith('/admin') && !currentPath.startsWith('/admin/users') ? 'active' : '' %>">My Music</a>
<% if (user.role === 'admin') { %>

View File

@ -23,7 +23,7 @@
aria-pressed="<%= playlist.userFavorited ? 'true' : 'false' %>"
title="Favorite">⭐ <span class="social-count"><%= playlist.favoriteCount || 0 %></span></button>
</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-left">
<button type="button" class="btn-primary"
@ -45,7 +45,11 @@
</div>
</div>
<% if (creator) { %>
<div class="detail-creator-card">
<% if (creator.slug) { %>
<a href="/profiles/<%= creator.slug %>" class="detail-creator-card">
<% } else { %>
<div class="detail-creator-card">
<% } %>
<% if (creator.avatarPath) { %>
<img src="/media/<%= creator.avatarPath %>" alt="" class="detail-creator-avatar">
<% } else { %>
@ -55,7 +59,11 @@
<% } %>
<p class="detail-creator-label">Created by</p>
<p class="detail-creator-name"><%= creator.displayName %></p>
</div>
<% if (creator.slug) { %>
</a>
<% } else { %>
</div>
<% } %>
<% } %>
</div>
</div>

View File

@ -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>

View File

@ -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>
<% } %>

View File

@ -37,7 +37,11 @@
<a href="/songs" class="btn-link">← Back to songs</a>
</div>
<% if (creator) { %>
<div class="detail-creator-card">
<% if (creator.slug) { %>
<a href="/profiles/<%= creator.slug %>" class="detail-creator-card">
<% } else { %>
<div class="detail-creator-card">
<% } %>
<% if (creator.avatarPath) { %>
<img src="/media/<%= creator.avatarPath %>" alt="" class="detail-creator-avatar">
<% } else { %>
@ -47,7 +51,11 @@
<% } %>
<p class="detail-creator-label">Added by</p>
<p class="detail-creator-name"><%= creator.displayName %></p>
</div>
<% if (creator.slug) { %>
</a>
<% } else { %>
</div>
<% } %>
<% } %>
</div>
</div>